"""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 ``###`` → ``

`` 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 ``![a](s)``, 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 = ( "

A plain paragraph with bold, em and " "code plus 1 < 2 & 3 > 2.

" "

Top heading

Second heading

Third heading

\n" "
  • first
  • \n
  • second
  • " '
    a quoted line with code
    ' "

    fenced: a * b, x_y_z

    " '

    \n' '\n' '\n' "\n" "
    Col ACol B
    12
    \n

    " ) def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") def _rules_for(css: str, selector: str) -> list[str]: """The body of every rule whose selector list contains *selector* verbatim (comment-stripped, whitespace-normalized) — the shape the phase-138 ``.md-quote`` pin uses, generalized to the grouped selectors task 04 writes for ``h5``/``h6``.""" stripped = re.sub(r"/\*.*?\*/", "", css, flags=re.S) bodies: list[str] = [] for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", stripped): selectors = [s.strip() for s in " ".join(match.group(1).split()).split(",")] if selector in selectors: bodies.append(match.group(2)) return bodies def _root_palette(css: str) -> set[str]: """Every variable name the ``:root`` block declares (the single source of truth the palette-only pins check against).""" root = re.search(r":root\s*\{", css) assert root, "styles.css must define :root" body = css[root.end() : css.find("}", root.end())] palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", body)) assert palette, "the :root palette must declare variables" return palette def _assert_palette_only(body: str, label: str) -> None: """No colour literal anywhere in the rule, and every ``var()`` reference is a declared :root palette var (the monochrome invariant + the :root-drift guard, cf. tests/unit/test_markdown_blockquotes.py).""" refs = re.findall(r"var\((--[a-z0-9-]+)\)", body) assert refs, f"{label} must reference palette vars" for ref in refs: assert ref in _ROOT, f"{label}: {ref} is not a :root palette var" assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), f"no hex color literal in {label}" assert "rgb(" not in body and "rgba(" not in body and "hsl(" not in body, ( f"no rgb()/hsl() color literal in {label}" ) _ROOT: set[str] = _root_palette(_text(STYLES_CSS)) def _run_node(script: str) -> str: proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60) assert proc.returncode == 0, f"node failed: {proc.stderr}" return proc.stdout def _render(cases: dict[str, str]) -> dict[str, str]: """Render every case through the REAL renderer under node and return the ``{name: html}`` map (the phase-138 harness shape).""" js = _text(MARKDOWN_JS) driver = ( js + "\nconst out = {}; " + "".join(f"out.{name} = renderMarkdown({value!r}); " for name, value in cases.items()) + "console.log(JSON.stringify(out));" ) results: dict[str, str] = json.loads(_run_node(driver)) return results # ---------- markdown.js: the link rule in the inline chain ---------- def test_inline_chain_order_is_emphasis_images_link_headings_lists() -> None: """The inline chain order is code → strong → em → **linked-image → image → link** → headings → lists (phase 180 D1/D3/D4): * the link rule runs AFTER the emphasis rules (so link text carrying ``**strong**``/``*em*``/``code`` is already transformed when it gets wrapped) and BEFORE the ``^### `` heading rule; * BOTH image rules sit AHEAD of the link rule — the linked-image rule first of all, so ``[![alt](src)](url)`` cannot leave a stray ``[`` inside the link text (LOCKED A6), then the plain image rule, so the ``!`` of an image is never orphaned into ``!
    ``. Everything else in the chain stays where the pre-phase renderer had it; all of it lives inside the shared ``inline()`` helper, so a table cell gets the identical pipeline (proved by the cell cases below).""" js = _text(MARKDOWN_JS) anchors = [ ('s.replace(/`([^`\\n]+)`/g, "$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, "
  • $1
  • ")', "bullet list"), ] positions: list[int] = [] for anchor, label in anchors: pos = js.find(anchor) assert pos != -1, f"missing the {label} rule in the inline chain: {anchor!r}" assert js.count(anchor) == 1, f"the {label} rule must appear exactly once" positions.append(pos) assert positions == sorted(positions), ( "the inline chain order must be code → strong → em → linked-image → " f"image → link → headings → lists, got rules at {positions!r} for " f"{[label for _, label in anchors]!r}" ) # every phase-180 rule is INSIDE the shared `inline` helper (so a # table cell gets it too — the cell cases below prove the behavior) inline_start = js.find("const inline = (s) =>") assert inline_start != -1, "the shared inline() helper is missing" for anchor, label in anchors: assert inline_start < js.find(anchor) < js.find("// 1b."), ( f"the {label} rule must live in the shared inline() chain, " "not in a one-off pass" ) def test_link_marker_and_external_target_rel_pair() -> None: """The exact output marker is ``
    " assert js.count("${mdAnchorOpen(safe)}") == 1, "the link rule uses the same builder" def test_link_url_allowlist_function_is_the_explicit_gate() -> None: """LOCKED A5: the URL is validated by an allowlist FUNCTION (not a regex-in-replacement). ``allowLinkUrl`` accepts ``http(s)://``, ``mailto:``, ``/``-rooted, ``./``/``../`` relative and ``#fragment`` URLs and returns them VERBATIM; everything else (``javascript:``, ``data:``, ``vbscript:``, ``file:``, ``blob:``) returns ``null``, and the caller then returns the ORIGINAL match — inert escaped text, never a live element and never a dropped string.""" js = _text(MARKDOWN_JS) assert "function allowLinkUrl(url) {" in js, "the allowlist is a named function" allowlist = re.search(r"function allowLinkUrl\(url\) \{(?P.*?)\n\}", js, re.S) assert allowlist, "allowLinkUrl must be a top-level function" body = allowlist.group("body") for shape, label in [ (r"https?:\/\/", "http(s)://"), (r"mailto:", "mailto:"), (r"\/", "/-rooted"), (r"\.{1,2}\/", "./ and ../ relative"), (r"#", "#fragment"), ]: assert shape in body, f"the allowlist must accept {label} URLs ({shape})" assert "null" in body, "a rejected URL must return null" assert "return _m;" in js, ( "a rejected URL must return the ORIGINAL matched text (inert, not dropped)" ) # the allowlist is a function call inside the replacement, and the # rejected branch is checked BEFORE any anchor is built. The anchor # markup itself is single-sourced in mdAnchorOpen (task 02's # linked-image rule is its second caller), so "precedes the anchor" # is asserted per rule: the allowlist call comes first, the builder # call only ever runs on an accepted URL. assert "const safe = allowLinkUrl(url);" in js, "the replacement calls the allowlist" anchor_code = js.find(' None: """Design D2 — escape-first IS the security boundary: ``inline()`` always receives HTML-escaped text, so a captured URL may contain ``&``/``'``/``"`` (valid in an href) and is interpolated VERBATIM. Any decoding/re-escaping would re-open the injection surface the renderer exists to close, so the file must contain no decoder and no HTML sink.""" js = _text(MARKDOWN_JS) for forbidden in ( "decodeURIComponent", "decodeURI(", "unescape(", "DOMParser", "innerHTML", "insertAdjacentHTML", "eval(", ): assert forbidden not in js, f"markdown.js must never use {forbidden} (design D2)" # ---------- markdown.js: the image + linked-image rules (task 02) ---------- def test_image_rules_emit_marker_alt_and_lazy() -> None: """Task 02's output contract at source level: BOTH image templates are ``…`` — the marker appears exactly twice (the plain rule + the linked-image rule) and EACH of them carries ``alt`` (always emitted, empty allowed — an ```` without an alt attribute fails WCAG 1.1) and ``loading="lazy"``. There is deliberately no inline ``onerror`` handler anywhere in the renderer: it returns an HTML string into innerHTML-style surfaces and the CSP/no-CDN posture forbids inline handlers, so a broken src degrades to the browser's alt text.""" js = _text(MARKDOWN_JS) # the two CODE templates (the marker string also appears in the prose # that explains it, so the interpolation shape is what is counted) assert js.count('\"${alt}\" — " "alt is always emitted, next to the lazy hint" ) assert js.count(LAZY_ATTR) >= 2 assert js.count(IMG_MARKER) >= 2 # (the word appears in the prose that explains WHY there is none — an # inline handler would be an attribute, so that is what is forbidden) for forbidden in (" onerror=", " onload=", " onclick=", " onerror ="): assert forbidden not in js, f"markdown.js must never emit {forbidden.strip()!r}" def test_linked_image_rule_checks_both_allowlists_before_emitting() -> None: """LOCKED A5 + A6 at source level: the linked-image rule captures THREE groups (alt, src, url) and validates BOTH URLs — the src through ``allowImageUrl``, the href through ``allowLinkUrl`` — with either rejection returning the ORIGINAL match. So an image wrapped in a link is only ever live when both halves pass.""" js = _text(MARKDOWN_JS) start = js.find(LINKED_IMAGE_RULE) assert start != -1, "the linked-image rule is missing" body = js[start : js.find(IMAGE_RULE)] assert "allowImageUrl(src);" in body, "the src goes through the image allowlist" assert "allowLinkUrl(url);" in body, "the href goes through the link allowlist" assert body.count("return _m;") == 2, "either rejection keeps the original text" assert IMG_MARKER in body, "the accepted case wraps the image inside the anchor" assert "" in body, "the anchor is closed after the image" # the plain image rule validates its src the same way plain = js[js.find(IMAGE_RULE) : js.find(LINK_RULE)] assert "allowImageUrl(src);" in plain and "return _m;" in plain, ( "the plain image rule is allowlist-gated too (locked A5)" ) def test_image_url_allowlist_function_is_the_explicit_gate() -> None: """LOCKED A5 for images: a named ``allowImageUrl`` function is the gate (not a regex-in-replacement). It accepts ``/``-rooted served paths, ``./``/``../`` relative paths, ``http(s)://`` and ``data:image/…`` (the app's own CSP already allows ``data:`` images — ``img-src 'self' data:``, so no CSP change is involved) and returns them VERBATIM; everything else returns ``null``.""" js = _text(MARKDOWN_JS) assert "function allowImageUrl(url) {" in js, "the image allowlist is a named function" allowlist = re.search(r"function allowImageUrl\(url\) \{(?P.*?)\n\}", js, re.S) assert allowlist, "allowImageUrl must be a top-level function" body = allowlist.group("body") for shape, label in [ (r"https?:\/\/", "http(s)://"), (r"data:image\/", "data:image/*"), (r"|\/|", "/-rooted"), (r".{0,2}\/", "./ and ../ relative"), ]: assert shape in body, f"the image allowlist must accept {label} URLs ({shape})" assert r"/^(?:https?:\/\/|data:image\/|\/|.{0,2}\/)/i" in body, ( "the image allowlist is ONE anchored alternation, case-insensitive" ) assert "null" in body, "a rejected src must return null" # the image allowlist never accepts a link-only scheme (a fragment is # not an image) and the link allowlist never accepts data: URLs assert "mailto" not in body, "mailto: is not an image src" link_allowlist = re.search(r"function allowLinkUrl\(url\) \{(?P.*?)\n\}", js, re.S) assert link_allowlist, "allowLinkUrl must be a top-level function" assert "data:" not in link_allowlist.group("b"), "links stay closed to data: URLs (A5)" def test_module_docstring_records_the_image_contract() -> None: """Task 02 work item 4: the module docstring carries the final chain order, the image allowlist, and the documented limits — including the size-less-SVG one that belongs to phase 181 (so the next reader does not re-decide it).""" js = _text(MARKDOWN_JS) docstring = js[: js.find("*/")] for needle, label in [ ("linked-image → image → link", "the final chain order"), ("allowImageUrl", "the image allowlist by name"), ('alt=""', "the empty-alt (decorative image) case"), ("img-src 'self' data:", "the CSP reason data: images are allowed"), ("viewBox", "the size-less-SVG limit"), ("phase 181", "where the vector sizing contract lives instead"), ("NO `onerror` handler", "the deliberate no-inline-handler choice"), ]: assert needle in docstring, f"the module docstring must record {label}: {needle!r}" # ---------- markdown.js: the heading ladder (task 03) ---------- #: The three pre-existing heading rules, whose output must stay exactly as #: it was (LOCKED A4 — the phase ADDS levels below ``###``, it does not #: re-rank the existing ones). H3_RULE = '.replace(/^### (.*)$/gm, "

    $1

    ")' H2_RULE = '.replace(/^## (.*)$/gm, "

    $1

    ")' H1_RULE = '.replace(/^# (.*)$/gm, "

    $1

    ")' #: The two rules task 03 adds (locked A4): ``####`` → ``
    `` and #: ``#####``/``######`` → ``
    `` (HTML has no ````, and the app's #: type scale stops two levels below ``###``). H4_RULE = '.replace(/^#### (.*)$/gm, "
    $1
    ")' H56_RULE = '.replace(/^#{5,6} (.*)$/gm, "
    $1
    ")' #: The step-3 paragraph guard. ``h\\d`` already covers ``h5``/``h6``, so #: task 03 deliberately changed NOTHING here — pinned, not assumed. PARAGRAPH_GUARD = "/^<(h\\d|ul|ol|pre|li|blockquote)/" def test_heading_ladder_rules_sit_deeper_first_before_h3() -> None: """Task 03 work items 1–2 (LOCKED A4) at source level: the two new rules are written DEEPER-FIRST — ``/^#### /`` then ``/^#{5,6} /`` — immediately BEFORE the existing ``/^### /`` rule, and the existing ``#``/``##``/``###`` rules are untouched (``#``/``##`` still map to ``

    ``, ``###`` still to ``

    ``). Every rule requires a SPACE after its hash run, which is what makes cross-matching impossible (``#### x`` never matches ``/^### /``, ``##### x`` never matches ``/^#### /``) so a deeper heading can never be partially eaten by a shallower rule. Every rung lives in the shared ``inline()`` chain, so a table cell gets the ladder too.""" js = _text(MARKDOWN_JS) ladder = [ (H4_RULE, "#### →

    "), (H56_RULE, "#####/###### →
    "), (H3_RULE, "### →

    "), (H2_RULE, "## →

    "), (H1_RULE, "# →

    "), ] for rule, label in ladder: assert js.count(rule) == 1, f"the {label} rule must appear exactly once: {rule!r}" # every rule captures AFTER a literal space, which is what makes # cross-matching impossible (`#### x` never matches `/^### /`) assert " (.*)$/gm" in rule, f"{rule!r} must require a space after its hashes" # the ladder reads top-down by depth: the new rungs come first for earlier, later, label in [ (H4_RULE, H56_RULE, "#### before #####/######"), (H56_RULE, H3_RULE, "#####/###### before ###"), (H3_RULE, H2_RULE, "### before ##"), (H2_RULE, H1_RULE, "## before #"), ]: assert js.find(earlier) < js.find(later), f"ladder order broken: {label}" assert js.find(H1_RULE) < js.find('.replace(/^\\s*[-*] '), ( "the whole ladder must stay above the list rules" ) inline_start = js.find("const inline = (s) =>") for rule, label in [(H4_RULE, "####"), (H56_RULE, "#####/######")]: assert inline_start < js.find(rule) < js.find("// 1b."), ( f"the {label} rule must live in the shared inline() chain" ) def test_step3_paragraph_guard_already_covers_h5_h6() -> None: """Task 03 work item 3: the step-3 block guard is ``h\\d``-based, so it ALREADY lets an ``

    ``/``
    `` block through unwrapped and NO change was needed there — this pins that the guard is still the untouched one (narrowing it to ``h[1-4]`` would silently re-wrap every deep heading in a ``

    ``).""" 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 → ``

    ``, ``#####``/``######`` both → ``

    ``) and the space-after-hashes / column-0 contract, so the next reader does not re-decide it.""" js = _text(MARKDOWN_JS) docstring = js[: js.find("*/")] # strip the comment-continuation ` * ` markers, then re-flow: the pin is # about the CONTENT of the docstring, not its line-wrapping flat = " ".join( " ".join(line.strip().removeprefix("*").strip() for line in docstring.splitlines()).split() ) for needle, label in [ ("`#`/`##` → `

    `", "the deliberate #/## flattening"), ("`###` → `

    `", "the ### rung"), ("`####` → `

    `", "the #### rung"), ("`#####` and `######` → `
    `", "the #####/###### rung"), ("locked A4", "the locked decision behind the ladder"), ("column 0", "the column-0 (m-flag) contract"), ("DELIBERATE", "that the flattening is deliberate"), ]: assert needle in flat, f"the module docstring must record {label}: {needle!r}" # ---------- styles.css: the task 04 stylesheet contract ---------- def test_md_link_rules_exist_and_reference_palette_vars_only() -> None: """Task 04's link styling: ``.md-link`` paints the theme's AA-safe brand text colour (``--brand-ink``, 9.9:1 on ``--surface``) and is underlined UNCONDITIONALLY — a link in running text must not rely on colour alone (WCAG 1.4.1) — with ``--brand-hover`` on ``:hover``; ``.msg.user .bubble .md-link`` is the ``.md-quote`` inversion (the bubble's own ``--bg`` ink on the brand fill, 5.2:1) and keeps its own ``:hover``. Every one of those rules references :root palette vars ONLY (no hex/rgb/hsl literal: the :root-drift unit test parses the built-ins out of this file, so a literal would be a second source of truth and a new hue would break the monochrome invariant), and the rule block carries the house contrast-ratio note.""" css = _text(STYLES_CSS) for selector in ( ".md-link", ".md-link:hover", ".msg.user .bubble .md-link", ".msg.user .bubble .md-link:hover", ): bodies = _rules_for(css, selector) assert bodies, f"styles.css must define {selector}" for body in bodies: _assert_palette_only(body, selector) link_body = _rules_for(css, ".md-link")[0] assert "color: var(--brand-ink)" in link_body, ( ".md-link must use the :root AA-safe brand text token" ) assert "text-decoration: underline" in link_body, ( "a link must never rely on colour alone (WCAG 1.4.1)" ) assert "color: var(--brand-hover)" in _rules_for(css, ".md-link:hover")[0], ( ".md-link:hover must brighten on the shared --brand-hover token" ) # the user-bubble inversion (the .md-quote precedent at L588) inversion = _rules_for(css, ".msg.user .bubble .md-link")[0] assert "color: var(--bg)" in inversion, ( "the link inside the brand-filled user bubble keeps the bubble's ink" ) # the house convention: the comment above the rule carries the ratio rule_pos = css.find(".md-link {") assert rule_pos != -1 comment_block = css[css.rfind("/*", 0, rule_pos) : rule_pos] ratios = [float(r) for r in re.findall(r"(\d+(?:\.\d+)?)\s*:\s*1", comment_block)] assert ratios and max(ratios) >= 4.5, ( f"the .md-link rule must carry a >=4.5:1 contrast note, got {ratios!r}" ) def test_md_img_rule_caps_the_image_and_uses_the_card_language() -> None: """Task 04's image styling: ``.md-img`` is capped to its container (``max-width: 100%`` + ``height: auto`` + ``object-fit: contain``, no fixed width/height) so a wide diagram can never push a horizontal scrollbar out of an answer at 375px or 1440px, and it carries the app's existing bordered-image card language (the ``.source-image-img` treatment: ``--surface`` plate, ``--line`` hairline, ``--radius-sm``). Palette vars only — no literal.""" bodies = _rules_for(_text(STYLES_CSS), ".md-img") assert bodies, "styles.css must define .md-img" body = bodies[0] _assert_palette_only(body, ".md-img") for declaration in ("max-width: 100%", "height: auto", "object-fit: contain"): assert declaration in body, f".md-img must declare {declaration}" for token in ("var(--surface)", "var(--line)", "var(--radius-sm)"): assert token in body, f".md-img must carry the card language ({token})" assert not re.search(r"^\s*(?:width|height):\s*\d", body, re.M), ( ".md-img must not pin a fixed width/height (any aspect ratio fits)" ) def test_h5_h6_type_scale_extends_the_ladder_monotonically() -> None: """Task 04's heading ladder: the renderer emits ``
    ``/``
    `` and neither tag had a rule before the phase, so ONE grouped rule per level covers every renderer context (the viewer's ``.doc-md``, the chat and shared-page ``.msg .bubble``, the ``.thinking-text`` scratchpad — no chat-specific duplication) and sizes them into the existing ``.doc-md`` scale MONOTONICALLY (h3 1.15rem → h4 1rem → h5 0.95rem → h6 0.88rem), in rem (never px) so the ladder survives zoom, and with NO colour declared (each context keeps its own AA ink, so the two new rungs add no new contrast pair).""" css = _text(STYLES_CSS) for level, context in (("h5", ".doc-md h5"), ("h6", ".doc-md h6")): bodies = _rules_for(css, context) assert bodies, f"styles.css must size the {level} rung ({context})" body = bodies[0] size = re.search(r"font-size:\s*([\d.]+)rem", body) assert size, f"{context} must set font-size in rem" assert "px" not in body.split("font-size")[1].split(";")[0], ( f"{context} font-size must be rem, not px" ) assert "color" not in body, f"{context} must not declare a colour" # ONE rule set for every renderer context (no chat duplication) assert _rules_for(css, f".msg .bubble {level}"), f"missing .msg .bubble {level}" assert _rules_for(css, f".thinking-text {level}"), f"missing .thinking-text {level}" def rem(selector: str) -> float: found = re.search( r"font-size:\s*([\d.]+)rem", _rules_for(css, selector)[0] ) assert found, f"{selector} must set a rem font-size" return float(found.group(1)) ladder = [rem(s) for s in (".doc-md h3", ".doc-md h4", ".doc-md h5", ".doc-md h6")] assert ladder == sorted(ladder, reverse=True) and len(set(ladder)) == 4, ( f"the type scale must be strictly monotonic, got {ladder!r}" ) # ---------- behavior (node): the story's acceptance shapes ---------- @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_link_transform_shapes_under_node() -> None: """The story shapes executed under node (TODO.md L4): (1) an absolute link renders with ``target="_blank" rel="noopener noreferrer"`` and a ``&`` in the query string keeping its escaped ``&`` form verbatim; (2) ``http://`` too; (3) ``mailto:``, ``/``-rooted, ``./``/``../`` and ``#fragment`` links render WITHOUT target/rel (same tab); (4) link text carrying already-transformed markup keeps it inside the anchor; (5) HTML in the link text/URL stays escaped — the anchor is the only live element; (6) an empty link text is allowed; (7) a link inside a fenced block stays fence content (step 1 protects first); (8) a link inside a table cell renders (the cell goes through the same ``inline()``).""" empty = "[](https://x.test)" cases = { "abs": "see [the docs](https://example.com/a?x=1&y=2)", "http": "[a](http://x.test)", "mailto": "[m](mailto:reese@example.com)", "root": "[r](/docs/thing.md)", "dotrel": "[d](./x.md)\n\n[u](../y.md)", "frag": "[f](#section-2)", "rich": "[**bold** *i* `c`](https://x.test)", "esc": "[](https://x.test?a=1&b=2)", "empty": empty, "fence": "```\n[a](https://x.test)\n```", "cell": "| h |\n|---|\n| [cell](https://x.test) |", } exact = { "abs": ( '

    see the docs

    ' ), "http": ( '

    a

    ' ), "mailto": '

    m

    ', "root": '

    r

    ', "dotrel": ( '

    d

    ' '

    u

    ' ), "frag": '

    f

    ', "rich": ( '

    ' "bold i c

    " ), "esc": ( '

    ' "<script>alert(1)</script>

    " ), "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 ( 'cell' ) in results["cell"], "a link inside a table cell renders through inline()" 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": "![topology](/api/documents/aaaa/image)", "assets": "![logo](/assets/brand.svg)", "data": "![i](data:image/svg+xml;base64,PHN2Zz4=)", "abs": "![a](https://x.test/i.png)", "dotrel": "![img](./x.png)\n\n![up](../y.png)", "empty_alt": "![](/a.png)", "esc_alt": "![he said \"hi\" & more](/a.png)", "two": "![a](/a.png)![b](/b.png)", "quote": "> ![q](/a.png)", "fence": "```\n![a](/a.png)\n```", "cell": "| h |\n|---|\n| ![c](/a.png) |", } exact = { "served": ( '

    ' ), "assets": ( '

    logo

    ' ), "data": ( '

    ' ), "abs": ( '

    a

    ' ), "dotrel": ( '

    img

    ' '

    up

    ' ), "empty_alt": '

    ', "esc_alt": ( '

    ' ), "two": ( '

    a' 'b

    ' ), "quote": ( '
    ' 'q' "
    " ), "fence": "

    ![a](/a.png)

    ", } 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 'c' 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 ``![alt](src)`` 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 ``![alt](url)`` 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](#frag)", '

    !f

    ', ), "mailto": ( "![a](mailto:x@y.test)", '

    !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: ``[![alt](src)](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": "[![d](/a.svg)](https://example.com)", "query": "[![linked](data:image/png;base64,YQ==)](https://x.test/x?a=1&b=2)", "relative": "[![d](/a.svg)](/page)", "mailto": "[![d](/a.svg)](mailto:a@x.test)", "bad_href": "[![d](/a.svg)](javascript:alert(1))", "bad_src": "[![d](javascript:alert(1))](https://example.com)", } exact = { "abs": ( '' 'd' ), "query": ( '' 'linked' ), "relative": ( '' 'd' ), "mailto": ( '' 'd' ), } 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 "

    " 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"

    {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
    \n
    Deepest
    ", "h6": "
    Deepest
    ", # the pre-existing ladder, unchanged (LOCKED A4) "existing": "

    A

    B

    C

    ", "then_para": "
    H

    A paragraph after.

    ", "rich": "
    Deep with bold and c
    ", "fence": "

    #### Deep

    ", "midline": "

    text 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 a

    for name in ("h4", "h5_h6", "h6", "then_para", "rich"): 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}

    ", f"{name}: {results[name]!r} stays text" assert " 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}"