10 changed files with 499 additions and 23 deletions
@ -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 @@ |
|||||||
|
"""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