10 changed files with 499 additions and 23 deletions
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
"""NIP-30 custom emoji: ``:shortcode:`` in text from ``emoji`` tags on the same event.""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import html |
||||
import re |
||||
from typing import cast |
||||
|
||||
# NIP-30: shortcode names are alphanumeric, hyphens, and underscores. |
||||
_SHORTCODE = re.compile(r":([a-zA-Z0-9_-]+):") |
||||
|
||||
|
||||
def parse_kind30030_a_coordinate(coord: str) -> tuple[str, str] | None: |
||||
""" |
||||
Parse an ``a`` tag value like ``30030:<hex64>:<d-identifier>`` (NIP-33 replaceable). |
||||
Returns ``(pubkey_lower, d_value)`` or ``None``. |
||||
""" |
||||
parts = coord.strip().split(":", 2) |
||||
if len(parts) != 3: |
||||
return None |
||||
kind_s, pk, d_val = parts[0].strip(), parts[1].strip().lower(), parts[2] |
||||
if kind_s != "30030": |
||||
return None |
||||
if len(pk) != 64 or any(c not in "0123456789abcdef" for c in pk): |
||||
return None |
||||
if not d_val: |
||||
return None |
||||
return pk, d_val |
||||
|
||||
|
||||
def nip30_emoji_urls_from_tags(tags: object) -> dict[str, str]: |
||||
""" |
||||
Build ``shortcode_lower -> https URL`` from ``["emoji", shortcode, url, ...?]`` tags. |
||||
Only ``http`` / ``https`` image URLs are kept. |
||||
""" |
||||
out: dict[str, str] = {} |
||||
if not isinstance(tags, list): |
||||
return out |
||||
for row_obj in cast(list[object], tags): |
||||
if not isinstance(row_obj, list): |
||||
continue |
||||
r = cast(list[object], row_obj) |
||||
if len(r) < 3: |
||||
continue |
||||
if str(r[0]).lower() != "emoji": |
||||
continue |
||||
code = str(r[1]).strip() |
||||
url = str(r[2]).strip() |
||||
if not code or not url or not re.fullmatch(r"[a-zA-Z0-9_-]+", code): |
||||
continue |
||||
if not (url.startswith("https://") or url.startswith("http://")): |
||||
continue |
||||
out[code.lower()] = url |
||||
return out |
||||
|
||||
|
||||
def _replace_shortcodes_in_segment(segment: str, url_by_shortcode: dict[str, str]) -> str: |
||||
if not url_by_shortcode or not segment: |
||||
return segment |
||||
|
||||
def repl(m: re.Match[str]) -> str: |
||||
raw = m.group(1) |
||||
u = url_by_shortcode.get(raw.lower()) |
||||
if not u: |
||||
return m.group(0) |
||||
esc_u = html.escape(u, quote=True) |
||||
esc_alt = html.escape(f":{raw}:", quote=True) |
||||
return ( |
||||
f'<img src="{esc_u}" alt="{esc_alt}" width="20" height="20" ' |
||||
'class="nip30-emoji" style="vertical-align:middle;max-height:1.35em;width:auto">' |
||||
) |
||||
|
||||
return _SHORTCODE.sub(repl, segment) |
||||
|
||||
|
||||
def preprocess_nip30_emoji_markdown(md: str, url_by_shortcode: dict[str, str]) -> str: |
||||
""" |
||||
Replace ``:shortcode:`` with inline ``<img>`` (NIP-30) in Markdown source. |
||||
Skips fenced ``` … ``` blocks so shortcodes inside code samples stay literal. |
||||
""" |
||||
if not url_by_shortcode or not md: |
||||
return md |
||||
pieces: list[str] = [] |
||||
i = 0 |
||||
while i < len(md): |
||||
j = md.find("```", i) |
||||
end = len(md) if j == -1 else j |
||||
pieces.append(_replace_shortcodes_in_segment(md[i:end], url_by_shortcode)) |
||||
if j == -1: |
||||
break |
||||
k = md.find("```", j + 3) |
||||
if k == -1: |
||||
pieces.append(md[j:]) |
||||
break |
||||
pieces.append(md[j : k + 3]) |
||||
i = k + 3 |
||||
return "".join(pieces) |
||||
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
"""NIP-30 custom emoji: ``emoji`` tags + ``:shortcode:`` in note text.""" |
||||
|
||||
import tempfile |
||||
from pathlib import Path |
||||
|
||||
from imwald.core.database import Database |
||||
from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary |
||||
from imwald.core.nip30_emoji import ( |
||||
nip30_emoji_urls_from_tags, |
||||
parse_kind30030_a_coordinate, |
||||
preprocess_nip30_emoji_markdown, |
||||
) |
||||
from imwald.core.nostr_crypto import build_signed_event, pubkey_hex_from_secret |
||||
|
||||
|
||||
def test_urls_from_tags() -> None: |
||||
tags = [ |
||||
["emoji", "100percent", "https://example.com/a.png"], |
||||
["emoji", "bad", "ftp://x/y.png"], |
||||
["emoji", "", "https://example.com/z.png"], |
||||
["t", "nostr"], |
||||
] |
||||
m = nip30_emoji_urls_from_tags(tags) |
||||
assert m == {"100percent": "https://example.com/a.png"} |
||||
|
||||
|
||||
def test_preprocess_skips_fenced_blocks() -> None: |
||||
urls = {"x": "https://example.com/x.png"} |
||||
md = "```\n:x:\n```\nline :x: end" |
||||
out = preprocess_nip30_emoji_markdown(md, urls) |
||||
assert ":x:" in out.split("```")[1] |
||||
assert '<img src="https://example.com/x.png"' in out |
||||
|
||||
|
||||
def test_markdown_html_fragment_nip30() -> None: |
||||
tags = [["emoji", "100percent", "https://example.com/e.png"]] |
||||
html = markdown_html_fragment("Hello :100percent: world", nip30_tags=tags) |
||||
assert 'class="nip30-emoji"' in html |
||||
assert 'src="https://example.com/e.png"' in html |
||||
assert "Hello" in html and "world" in html |
||||
|
||||
|
||||
def test_parse_kind30030_a_coordinate() -> None: |
||||
pk = "a" * 64 |
||||
assert parse_kind30030_a_coordinate(f"30030:{pk}:my-pack") == (pk, "my-pack") |
||||
assert parse_kind30030_a_coordinate("1:xx:yy") is None |
||||
assert parse_kind30030_a_coordinate("nope") is None |
||||
|
||||
|
||||
def test_author_kind0_inventory_resolves_shortcode_in_markdown() -> None: |
||||
"""Jumble-style: shortcodes in the note resolve from the author's kind 0 ``emoji`` tags.""" |
||||
sk = bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683") |
||||
pk = pubkey_hex_from_secret(sk) |
||||
k0 = build_signed_event( |
||||
sk, |
||||
created_at=1, |
||||
kind=0, |
||||
tags=[["emoji", "chadyes_sm", "https://example.com/chad.png"]], |
||||
content="{}", |
||||
) |
||||
with tempfile.TemporaryDirectory() as td: |
||||
db = Database(Path(td) / "nip30.sqlite") |
||||
db.connect() |
||||
db.upsert_event(k0) |
||||
html = markdown_html_fragment( |
||||
"Hi :chadyes_sm: bye", |
||||
db=db, |
||||
nip30_tags=None, |
||||
nip30_author_pubkey=pk, |
||||
) |
||||
assert "nip30-emoji" in html |
||||
assert "example.com/chad.png" in html |
||||
|
||||
|
||||
def test_event_emoji_tags_override_author_inventory() -> None: |
||||
sk = bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683") |
||||
pk = pubkey_hex_from_secret(sk) |
||||
k0 = build_signed_event( |
||||
sk, |
||||
created_at=1, |
||||
kind=0, |
||||
tags=[["emoji", "x", "https://example.com/from0.png"]], |
||||
content="{}", |
||||
) |
||||
with tempfile.TemporaryDirectory() as td: |
||||
db = Database(Path(td) / "nip30b.sqlite") |
||||
db.connect() |
||||
db.upsert_event(k0) |
||||
html = markdown_html_fragment( |
||||
":x:", |
||||
db=db, |
||||
nip30_tags=[["emoji", "x", "https://example.com/from_note.png"]], |
||||
nip30_author_pubkey=pk, |
||||
) |
||||
assert "from_note.png" in html |
||||
assert "from0.png" not in html |
||||
|
||||
|
||||
def test_plain_summary_strips_inline_emoji_img() -> None: |
||||
"""List previews strip HTML; NIP-30 ``<img>`` does not leave the shortcode in plain text.""" |
||||
tags = [["emoji", "x", "https://example.com/x.png"]] |
||||
s = markdown_plain_summary("Hi :x: bye", max_len=200, nip30_tags=tags) |
||||
assert "Hi" in s and "bye" in s |
||||
assert ":x:" not in s |
||||
Loading…
Reference in new issue