"""Unit: the shared renderer's inline link/image/heading transforms (phase 180).
Browser behavior is E2E-covered by ``tests/e2e/test_markdown_inline_links_images.py``
(task 05); here we pin the source-level contract of the ONE ``inline()``
chain in ``frontend/assets/markdown.js`` — the chain ORDER (code → strong
→ em → linked-image → image → link → headings → lists), the exact
```` and ``###`` → `` A plain paragraph with bold, em and "
"`` output is
byte-identical, and the step-3 paragraph guard is UNCHANGED (its ``h\\d``
already covers ``h5``/``h6`` — pinned, not assumed).
Task 04's stylesheet contract is pinned here too (the phase's ONE unit
file): the ``.md-link`` / ``.md-img`` rules and the ``h5``/``h6`` type
scale exist, reference :root palette variables ONLY (no hex/rgb/hsl
literal — a new hue would break the monochrome invariant), the link is
underlined (a link never relies on colour alone, WCAG 1.4.1), the user
bubble carries its own ``.md-link`` inversion, every image is capped to
its container (``max-width: 100%`` + ``height: auto``), the deep rungs
extend the existing ``h3``/``h4`` scale MONOTONICALLY, and the ``.md-link`
rule carries its house contrast-ratio note.
The browser-side acceptance evidence is the phase's dedicated Playwright
suite, ``tests/e2e/test_markdown_inline_links_images.py`` (task 05).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
MARKDOWN_JS = ASSETS / "markdown.js"
STYLES_CSS = ASSETS / "styles.css"
HAVE_NODE = shutil.which("node") is not None
#: The exact output marker of the link transform (task 01).
LINK_MARKER = ' {"""
#: The exact output marker of the image transform (task 02).
IMG_MARKER = '
{"""
)
#: The plain image rule — same shape, two captures.
IMAGE_RULE = r""".replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src) => {"""
#: The attributes every ``
`` template in the file carries: ``alt`` is
#: ALWAYS emitted (empty allowed — an ``
`` without alt fails WCAG
#: 1.1) next to ``loading="lazy"``, right after the allowlisted ``src``.
IMG_ALT_LAZY = 'alt="${alt}" loading="lazy">'
#: An input containing NONE of the phase-180 constructs (no ``[t](u)``,
#: no ````, no ``####``+) and its byte-EXACT pre-phase renderer
#: output — the byte-identity contract (phase 180 design D7): anything
#: without a link/image/deep-heading renders exactly as before. Covers
#: strong/em/code, the ``#``–``###`` ladder, both list kinds, a
#: blockquote, a fence and a pipe table.
NO_CONSTRUCT_INPUT = (
"A plain paragraph with **bold**, *em* and `code` plus 1 < 2 & 3 > 2.\n"
"\n"
"# Top heading\n"
"\n"
"## Second heading\n"
"\n"
"### Third heading\n"
"\n"
"- bullet one\n"
"- bullet two\n"
"\n"
"1. first\n"
"2. second\n"
"\n"
"> a quoted line with `code`\n"
"\n"
"```\n"
"fenced: a * b, x_y_z\n"
"```\n"
"\n"
"| Col A | Col B |\n"
"|---|---|\n"
"| 1 | 2 |\n"
)
NO_CONSTRUCT_PRE_PHASE_OUTPUT = (
"
code plus 1 < 2 & 3 > 2.Top heading
Second heading
Third heading
\n"
"a quoted line with
'
"codefenced: a * b, x_y_z
| Col A | Col B |
|---|---|
| 1 | 2 |
$1")', "code span"),
('.replace(/\\*\\*([^*]+)\\*\\*/g, "$1")', "strong"),
('.replace(/(^|[\\s(])\\*([^*\\n]+)\\*/g, "$1$2")', "em"),
(LINKED_IMAGE_RULE, "linked image"),
(IMAGE_RULE, "image"),
(LINK_RULE, "link"),
(H3_RULE, "### heading"),
('.replace(/^\\s*[-*] (.*)$/gm, "``).""" js = _text(MARKDOWN_JS) assert PARAGRAPH_GUARD in js, ( f"the step-3 guard must stay {PARAGRAPH_GUARD!r} (h\\d covers h5/h6)" ) assert js.count(PARAGRAPH_GUARD) == 1, "there is exactly one paragraph guard" assert "h[1-4]" not in js, "the guard must not be narrowed below h\\d" def test_module_docstring_records_the_heading_ladder() -> None: """Task 03 work item 4: the module docstring documents the deliberate flattening (``#``/``##`` both → ``
see the docs
' ), "http": ( '' ), "mailto": '', "root": '', "dotrel": ( '' '' ), "frag": '', "rich": ( '" ), "esc": ( '" ), "empty": ( '' ), "fence": "[a](https://x.test)",
}
results = _render(cases)
for name, html in results.items():
if name in exact:
assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}"
assert (
'None: """LOCKED A5 under node: a ``javascript:``, ``data:``, ``vbscript:``, ``file:`` or ``blob:`` URL (any case) never becomes a live element — the whole ``[text](url)`` construct renders verbatim as the inert escaped text it was before, with no ``{src}", f"{name}: {html!r} must stay verbatim text" assert " None: """The allowlist function itself under node: accepted URLs come back VERBATIM (escape-first — a ``&`` in the query string is not decoded), everything else comes back ``null``.""" cases: list[tuple[str, str | None]] = [ # accepted — returned VERBATIM (escape-first: the & stays) ("https://x.test/a?x=1&y=2", "https://x.test/a?x=1&y=2"), ("http://x.test", "http://x.test"), ("HTTPS://X.TEST", "HTTPS://X.TEST"), ("mailto:a@x.test", "mailto:a@x.test"), ("/docs/x.md", "/docs/x.md"), ("./x.md", "./x.md"), ("../x.md", "../x.md"), ("#frag", "#frag"), # rejected -> null (LOCKED A5): the construct stays inert text ("javascript:alert(1)", None), ("JaVaScRiPt:alert(1)", None), ("data:text/html,x", None), ("vbscript:x", None), ("file:///etc/passwd", None), ("blob:https://x.test/u", None), ("x.test/no-scheme", None), ("", None), ("..%2fetc%2fpasswd", None), ("javascript:alert(1)", None), ] driver = ( _text(MARKDOWN_JS) + "\nconst in_ = " + json.dumps([c[0] for c in cases]) + ";\nconsole.log(JSON.stringify(in_.map(allowLinkUrl)));" ) got = json.loads(_run_node(driver)) assert got == [expected for _, expected in cases], f"allowLinkUrl mismatch: {got!r}" @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_image_transform_shapes_under_node() -> None: """The image shapes executed under node (TODO.md L3): a served-path image (the app's own ``/api/documents//image`` shape), a ``data:image/*`` image (CSP ``img-src 'self' data:`` already permits it), an absolute ``http(s)://`` image, ``./``/``../`` relative images, an EMPTY alt (decorative — the attribute is still there), HTML in the alt staying escaped (so the attribute cannot be broken out of), two images on one line, an image inside a blockquote, an image inside a fenced block staying code (step 1 protects first), and an image inside a table cell rendering through the same ``inline()``.""" cases = { "served": "", "assets": "", "data": "", "abs": "", "dotrel": "\n\n", "empty_alt": "", "esc_alt": "", "two": "", "quote": "> ", "fence": "```\n\n```", "cell": "| h |\n|---|\n|  |", } exact = { "served": ( ' " in html # the href-only rejection: the image still renders, the anchor does not href_rejected = results["bad_href"] assert LINK_MARKER not in href_rejected and "href" not in href_rejected assert IMG_MARKER in href_rejected, "documented fall-through to the image rule" # the src-only rejection: everything stays verbatim text src_rejected = results["bad_src"] assert src_rejected == f"' ), "assets": ( '
' ), "data": ( '
' ), "abs": ( '
' ), "dotrel": ( '
' '
' ), "empty_alt": '
', "esc_alt": ( '
' ), "two": ( '
' ), "quote": ( '
' '
' '" ), "fence": "' "
", } results = _render(cases) for name, html in results.items(): if name in exact: assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}" assert '' in results[ "cell" ], "an image inside a table cell renders through inline()" # every live image carries an alt AND the lazy hint — no exceptions for name in ("served", "assets", "data", "abs", "empty_alt", "cell"): start = results[name].find(IMG_MARKER) assert start != -1, f"{name}: no rendered — {results[name]!r}" tag = results[name][start : results[name].find(">", start) + 1] assert "alt=" in tag and LAZY_ATTR in tag, f"{name}: {tag!r} misses alt/lazy" @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_disallowed_image_srcs_stay_inert_text() -> None: """LOCKED A5 under node: a ``javascript:``, ``file:``, ``blob:``, ``vbscript:`` or NON-image ``data:`` src (any case) never becomes a live ``
`` — the whole ```` construct renders verbatim as the inert escaped text it was before, with no ``
{expected}", ( f"{name}: {html!r} must stay verbatim text for {src!r}" ) assert "
None: """Documented limit (pinned so a change is deliberate): an image src rejected by ``allowImageUrl`` but ACCEPTED by ``allowLinkUrl`` — a link-only shape used as an image src, i.e. ``#fragment`` or ``mailto:`` — reaches the link rule, so ```` renders as a link behind the leftover ``!``. The rejected src never becomes a live image, and the surviving element is an ordinary allowlisted link (the same shape the pre-phase renderer produced for that input).""" cases = { "frag": ( "", '
!f
', ), "mailto": ( "", '!a
', ), } results = _render({name: src for name, (src, _e) in cases.items()}) for name, (src, expected) in cases.items(): assert results[name] == expected, f"{name} ({src}): {results[name]!r}" assert IMG_MARKER not in results[name], f"{name}: {src} must not become an" @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_linked_image_wraps_without_stray_brackets() -> None: """LOCKED A6 under node: ``[](url)`` renders as ONE ```` wrapping ONE ``
`` with no stray ``[``/`]` left in the output — for an absolute href (which carries ``target``/``rel``, and keeps ``&`` in a query string verbatim), for a root-relative href and for a ``mailto:`` href (both without ``target``/``rel``). When only the HREF is rejected the construct falls through to the plain image rule (the image renders inside the still-inert ``[…](url)`` text — documented limit, never a live anchor); when only the SRC is rejected the whole construct stays verbatim text.""" cases = { "abs": "[](https://example.com)", "query": "[](https://x.test/x?a=1&b=2)", "relative": "[](/page)", "mailto": "[](mailto:a@x.test)", "bad_href": "[](javascript:alert(1))", "bad_src": "[)](https://example.com)", } exact = { "abs": ( '' '
' ), "query": ( '' '
' ), "relative": ( '' '
' ), "mailto": ( '' '
' ), } results = _render(cases) for name, inner in exact.items(): assert results[name] == f"
{inner}
", ( f"{name}: {results[name]!r} != contract {inner!r}" ) assert "[" not in results[name][3:-4] and "]" not in results[name][3:-4], ( f"{name}: {results[name]!r} keeps a stray bracket" ) for name in ("abs", "query", "relative", "mailto"): html = results[name] assert html.count(LINK_MARKER) == 1 and html.count(IMG_MARKER) == 1 assert f"{LINK_MARKER} " in html and "{cases['bad_src']}
", f"bad_src: {src_rejected!r}" assert IMG_MARKER not in src_rejected and LINK_MARKER not in src_rejected @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_allowimage_url_function_accepts_and_rejects() -> None: """The image allowlist itself under node: accepted srcs come back VERBATIM (escape-first — an ``&`` inside the src is not decoded), everything else comes back ``null``. ``data:`` is accepted ONLY for ``image/*`` (the app's CSP shape), and a link-only shape (``#frag``, ``mailto:``) is not an image.""" cases: list[tuple[str, str | None]] = [ # accepted — returned VERBATIM ("/api/documents/aaaa/image", "/api/documents/aaaa/image"), ("/assets/brand.svg", "/assets/brand.svg"), ("./x.png", "./x.png"), ("../y.png", "../y.png"), ("https://x.test/i.png", "https://x.test/i.png"), ("HTTP://X.TEST/I.PNG", "HTTP://X.TEST/I.PNG"), ("data:image/svg+xml;base64,PHN2Zz4=", "data:image/svg+xml;base64,PHN2Zz4="), ("DATA:IMAGE/PNG;base64,YQ==", "DATA:IMAGE/PNG;base64,YQ=="), ("/x.png?a=1&b=2", "/x.png?a=1&b=2"), # rejected -> null (LOCKED A5): the construct stays inert text ("javascript:alert(1)", None), ("JaVaScRiPt:alert(1)", None), ("data:text/html,x", None), ("data:image/png;base64,YQ==", None), ("file:///etc/passwd", None), ("blob:https://x.test/u", None), ("vbscript:x", None), ("x.test/i.png", None), ("images/x.png", None), ("#frag", None), ("mailto:a@x.test", None), ("", None), ("..%2fetc%2fpasswd", None), ] driver = ( _text(MARKDOWN_JS) + "\nconst in_ = " + json.dumps([c[0] for c in cases]) + ";\nconsole.log(JSON.stringify(in_.map(allowImageUrl)));" ) got = json.loads(_run_node(driver)) assert got == [expected for _, expected in cases], f"allowImageUrl mismatch: {got!r}" @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_heading_ladder_shapes_under_node() -> None: """TODO.md L5 under node: ``#### Deep`` renders ``Deep
`` with NO ```` wrapper and no literal ``####``; ``##### `` and ``###### `` both render ``
`` (two consecutive deep lines become two ``
`` elements, still unwrapped); ``###``/``##``/``#`` are BYTE-IDENTICAL to the pre-phase output; a deep heading immediately followed by a paragraph is not wrapped in ``
``; inline markup inside a deep heading still transforms; a ``####`` line inside a fence stays fence content (step 1 protects first); a ``####`` in a table cell renders through the same ``inline()``; and the non-heading shapes (a mid-line ``####``, an indented ``####``, a hash run with no space) stay text.""" cases = { "h4": "#### Deep", "h5_h6": "##### Deeper\n###### Deepest", "h6": "###### Deepest", "existing": "### A\n\n## B\n\n# C", "then_para": "#### H\n\nA paragraph after.", "rich": "#### Deep with **bold** and `c`", "fence": "```\n#### Deep\n```", "cell": "| h |\n|---|\n| #### cell head |", "midline": "text with a #### mid-line hash", "indented": " #### Not a heading", "no_space": "####No space", } exact = { "h4": "
Deep
", "h5_h6": "Deeper
\nDeepest
", "h6": "Deepest
", # the pre-existing ladder, unchanged (LOCKED A4) "existing": "A
B
C
", "then_para": "H
A paragraph after.
", "rich": "Deep with bold and
", "fence": "c", "midline": "#### Deeptext with a #### mid-line hash
", # the paragraph pass trims the block, the heading rule still does not fire "indented": "#### Not a heading
", "no_space": "####No space
", } results = _render(cases) for name, html in results.items(): if name in exact: assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}" # the untouched step-3 guard does its job: no heading inside afor name in ("h4", "h5_h6", "h6", "then_para", "rich"): assert "
", f"{name}: {results[name]!r} stays text" assert "
" assert results["h5_h6"].count(" ") == 2 and results["h5_h6"].count("
") == 2, ( "##### and ###### both render" ) assert "####" not in results["h4"], "the hash syntax must not survive" assert "
cell head
" in results["cell"], ( "a deep heading inside a table cell renders through inline()" ) @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_heading_ladder_stops_at_h6_and_never_invents_levels() -> None: """LOCKED A4 under node: the deep run is ``#{5,6}``, so a 7+ hash run is NOT a heading (``####### Seven`` has no space after five/six hashes at column 0) and the renderer never invents an element HTML does not have — there is no ````/`` `` in the source or the output.""" js = _text(MARKDOWN_JS) # (` ` appears in the prose that explains WHY there is none, so the # pin is on the output-template form, not the bare tag name) for forbidden in ('" {src} None: """The byte-identity contract (phase 180 design D7): an input with no link, no image ref and no ``####``+ heading renders BYTE-EXACTLY as the pre-phase renderer did (the fixture string and its pre-phase output are checked into this test, per the phase-44/138 shape) — which is what keeps the phase-44 table suite and the phase-138 blockquote suite green unedited.""" results = _render({"no_construct": NO_CONSTRUCT_INPUT}) assert results["no_construct"] == NO_CONSTRUCT_PRE_PHASE_OUTPUT assert LINK_MARKER not in results["no_construct"] assert IMG_MARKER not in results["no_construct"] assert LAZY_ATTR not in results["no_construct"] # the ladder added no levels ABOVE ####, so an input whose deepest # heading is ### gains no h5/h6 either for tag in (" Top heading", "
Second heading
", "Third heading
"): assert expected in results["no_construct"], f"ladder regression: {expected}"