diff --git a/CLAUDE.md b/CLAUDE.md index 38aa40e..337c65b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,8 @@ mdz is a **markdown dialect and renderer**: - CommonMark/GFM compatibility (it is a dialect, not a superset) - syntax highlighting (inject a highlighter — see the rendering seam) - a CSS framework or themed components (use fuz_css / fuz_ui) -- sanitization of arbitrary HTML (only registered components/elements render) +- sanitization of arbitrary HTML (only registered components/elements render, + and element attributes filter to a closed inert allowlist) ## Syntax @@ -135,7 +136,7 @@ mdz is a **markdown dialect and renderer**: | Code blocks | fenced with optional language hints; an unclosed fence consumes to EOF (or to the end of its enclosing blockquote) | | Horizontal rule | `---` on its own line | | Tables | `\| a \| b \|` rows + a `\| --- \| :-: \|` delimiter row (colons set per-column alignment); leading **and** trailing `\|` required; inline-only cells (`` `code` `` protects pipes, `\|` is a literal pipe); a header/delimiter column-count mismatch stays a paragraph; body rows pad/truncate at render | -| Components / elements | `` / `` (must be registered) | +| Components / elements | `` / `` (must be registered); attributes are quoted strings (`"` or `'`) or bare booleans (``); **elements** filter to a closed inert allowlist (`class`, `title`, `lang`, `dir`, `role`, `aria-label`/`-hidden`/`-describedby`/`-labelledby`), **components** pass all attributes through as props; malformed forms (unquoted, spaced `=`, `={…}`, duplicate name, newline in the open tag) stay literal text | | Paragraphs / breaks | blank line between paragraphs; single newlines are soft breaks; `
` (registered) for hard breaks | Whitespace follows standard markdown semantics: the parser preserves @@ -252,7 +253,11 @@ Svelte-shaped: the runtime views emit text through Svelte expressions `escape_svelte_text` — there is **no raw-HTML output path and no `{@html}` anywhere**. Unregistered components/elements render nothing, and link references pass the `mdz_is_safe_reference` protocol gate before becoming an -`href`. Any future raw-HTML emission (e.g. collapsing static subtrees to +`href`. Element attributes filter to a closed, hand-vetted allowlist of inert +names (nothing URL-, script-, or DOM-clobbering-bearing), so element safety is +independent of which element names a consumer registers; component attributes +pass through as props, since registering a component is itself the trust +decision. Any future raw-HTML emission (e.g. collapsing static subtrees to pre-rendered HTML strings rendered via `{@html}`) would move XSS-escaping out of Svelte and into mdz's own emitter — a trust-boundary change to design deliberately, not an incremental optimization. @@ -260,13 +265,13 @@ deliberately, not an incremental optimization. The three renderers (`MdzNodeView`, `MdzStreamNodeView`, `mdz_to_svelte`) share one grammar and stay in sync by construction: rendering *decisions* (link safety + resolve-vs-raw classification via `mdz_classify_link`, void -elements, heading ids, table header/body normalization) live in -`mdz_helpers.ts`, and the parity tests bind their outputs. `mdz_to_svelte` -emits **Svelte markup, not HTML** — `{`/`}` escape to Svelte expressions and -attribute values are expressions — so its output cannot be repurposed as raw -HTML. A fourth render target (say an HTML-string emitter) should be added by -parameterizing the emission over a target interface, not as another -hand-synced switch. +elements, element attribute filtering, heading ids, table header/body +normalization) live in `mdz_helpers.ts`, and the parity tests bind their +outputs. `mdz_to_svelte` emits **Svelte markup, not HTML** — `{`/`}` escape +to Svelte expressions and attribute values are expressions — so its output +cannot be repurposed as raw HTML. A fourth render target (say an HTML-string +emitter) should be added by parameterizing the emission over a target +interface, not as another hand-synced switch. ### Pre-computation @@ -349,4 +354,4 @@ covers the unconfigured plain defaults. - [`fuz_code`](../fuz_code/CLAUDE.md) — syntax highlighting; injected for code blocks - [`fuz_util`](../fuz_util/CLAUDE.md) — utility functions (peer dependency) -- [`fuz_css`](../fuz_css/CLAUDE.md) — CSS framework (dev dependency for the docs) +- [`fuz_css`](../fuz_css/CLAUDE.md) — semantic-first CSS framework (dev dependency for the docs) diff --git a/src/benchmarks/benchmark_inputs.ts b/src/benchmarks/benchmark_inputs.ts index bdb7a75..283f7d4 100644 --- a/src/benchmarks/benchmark_inputs.ts +++ b/src/benchmarks/benchmark_inputs.ts @@ -312,4 +312,34 @@ Functions with Array, Promise, and Map in prose. (_, i) => '['.repeat(200) + `text ${i} ` + ''.repeat(200) + `X${i}` + ''.repeat(200), ).join('\n\n'), }, + { + name: 'attribute heavy', + // The attribute-authoring success path: many correctly-closed tags each + // carrying several string and boolean attributes. Drives the attribute + // scan (name + quoted-value passes) and the `Set` dedup on the throughput + // path — the counterpart to the adversarial attribute inputs below. + content: Array.from( + {length: 300}, + (_, i) => + `Section ${i} with **${i}**.`, + ).join('\n\n'), + }, + { + name: 'many attributes single tag', + // One tag carrying a very large number of distinct attributes on a single + // line. Guards the `Set`-based duplicate-name check against an accidental + // O(n²) rescan of the collected list — an all-attributes line must stay + // linear (the same shape `mdz_scaling_ratio.test.ts` locks). + content: + ' `a${i}="${i}"`).join(' ') + '>x', + }, + { + name: 'hold line attribute value', + // One giant unterminated quoted attribute value (` import {resolve} from '$app/paths'; - import type {MdzNode, MdzNodeTableRow, MdzTableAlign} from './mdz.ts'; - import {mdz_classify_link, mdz_is_void_element} from './mdz_helpers.ts'; + import type {MdzAttribute, MdzNode, MdzNodeTableRow, MdzTableAlign} from './mdz.ts'; + import { + mdz_classify_link, + mdz_is_void_element, + mdz_filter_element_attributes, + mdz_attributes_to_props, + mdz_format_unregistered_attributes, + ALLOWED_ELEMENT_ATTRIBUTES, + } from './mdz_helpers.ts'; import { mdz_components_context, mdz_elements_context, @@ -37,30 +44,36 @@ tree instead of per node. --> {#snippet render_node(node: MdzNode)} {#if node.type === 'Element'} {@const element_config = get_elements?.()?.get(node.name)} - {#if element_config !== undefined} + {#if element_config === true} + {@const attrs = mdz_filter_element_attributes( + node.name, + node.attributes, + ALLOWED_ELEMENT_ATTRIBUTES, + )} {#if mdz_is_void_element(node.name)} - + {:else} - + {#if node.children.length > 0} {@render render_children(node.children)} {/if} {/if} {:else} - {@render render_unregistered_tag(node.name, node.children)} + {@render render_unregistered_tag(node.name, node.attributes, node.children)} {/if} {:else if node.type === 'Component'} {@const Component = get_components?.()?.get(node.name)} {#if Component} - + {@const props = mdz_attributes_to_props(node.attributes)} + {#if node.children.length > 0} {@render render_children(node.children)} {/if} {:else} - {@render render_unregistered_tag(node.name, node.children)} + {@render render_unregistered_tag(node.name, node.attributes, node.children)} {/if} {:else if node.type === 'Text'} {node.content} @@ -164,12 +177,17 @@ tree instead of per node. --> {/each} {/snippet} -{#snippet render_unregistered_tag(name: string, children: Array)} +{#snippet render_unregistered_tag( + name: string, + attributes: Array, + children: Array, +)} + {@const formatted = mdz_format_unregistered_attributes(attributes)} {#if children.length > 0} - <{name}>{@render render_children(children)}</{name}> + <{name}{formatted}>{@render render_children( + children, + )}</{name}> {:else} - <{name} /> + <{name}{formatted} /> {/if} {/snippet} diff --git a/src/lib/MdzStreamNodeView.svelte b/src/lib/MdzStreamNodeView.svelte index acc32f0..dd9efbd 100644 --- a/src/lib/MdzStreamNodeView.svelte +++ b/src/lib/MdzStreamNodeView.svelte @@ -1,7 +1,14 @@ + +{msg} diff --git a/src/test/MdzRenderHarness.svelte b/src/test/MdzRenderHarness.svelte new file mode 100644 index 0000000..04ae9aa --- /dev/null +++ b/src/test/MdzRenderHarness.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/src/test/fixtures/mdz/component_adjacent_bold/expected.json b/src/test/fixtures/mdz/component_adjacent_bold/expected.json index 54e2bcb..ace1c52 100644 --- a/src/test/fixtures/mdz/component_adjacent_bold/expected.json +++ b/src/test/fixtures/mdz/component_adjacent_bold/expected.json @@ -18,6 +18,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_at_end/expected.json b/src/test/fixtures/mdz/component_at_end/expected.json index 1946c2f..0cc2072 100644 --- a/src/test/fixtures/mdz/component_at_end/expected.json +++ b/src/test/fixtures/mdz/component_at_end/expected.json @@ -15,6 +15,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_at_start/expected.json b/src/test/fixtures/mdz/component_at_start/expected.json index 0d316ff..c6871f6 100644 --- a/src/test/fixtures/mdz/component_at_start/expected.json +++ b/src/test/fixtures/mdz/component_at_start/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_between_paragraphs/expected.json b/src/test/fixtures/mdz/component_between_paragraphs/expected.json index d300d64..02c8447 100644 --- a/src/test/fixtures/mdz/component_between_paragraphs/expected.json +++ b/src/test/fixtures/mdz/component_between_paragraphs/expected.json @@ -15,6 +15,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_breaks_inline/expected.json b/src/test/fixtures/mdz/component_breaks_inline/expected.json index 98c4506..9700984 100644 --- a/src/test/fixtures/mdz/component_breaks_inline/expected.json +++ b/src/test/fixtures/mdz/component_breaks_inline/expected.json @@ -20,6 +20,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_empty/expected.json b/src/test/fixtures/mdz/component_empty/expected.json index caa419d..3d0a8e1 100644 --- a/src/test/fixtures/mdz/component_empty/expected.json +++ b/src/test/fixtures/mdz/component_empty/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [], "start": 0, "end": 15 diff --git a/src/test/fixtures/mdz/component_inline/expected.json b/src/test/fixtures/mdz/component_inline/expected.json index fa4263d..766ee48 100644 --- a/src/test/fixtures/mdz/component_inline/expected.json +++ b/src/test/fixtures/mdz/component_inline/expected.json @@ -11,6 +11,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_multiline/expected.json b/src/test/fixtures/mdz/component_multiline/expected.json index 6cfdaa3..867d117 100644 --- a/src/test/fixtures/mdz/component_multiline/expected.json +++ b/src/test/fixtures/mdz/component_multiline/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_multiple/expected.json b/src/test/fixtures/mdz/component_multiple/expected.json index cf9a1c9..0128ab0 100644 --- a/src/test/fixtures/mdz/component_multiple/expected.json +++ b/src/test/fixtures/mdz/component_multiple/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", @@ -16,6 +17,7 @@ { "type": "Component", "name": "Card", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_nested/expected.json b/src/test/fixtures/mdz/component_nested/expected.json index 3fe26f6..15070d3 100644 --- a/src/test/fixtures/mdz/component_nested/expected.json +++ b/src/test/fixtures/mdz/component_nested/expected.json @@ -2,10 +2,12 @@ { "type": "Component", "name": "Card", + "attributes": [], "children": [ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_self_closing/expected.json b/src/test/fixtures/mdz/component_self_closing/expected.json index ece8e53..4c17157 100644 --- a/src/test/fixtures/mdz/component_self_closing/expected.json +++ b/src/test/fixtures/mdz/component_self_closing/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [], "start": 0, "end": 9 diff --git a/src/test/fixtures/mdz/component_simple/expected.json b/src/test/fixtures/mdz/component_simple/expected.json index d9e0da7..d776c57 100644 --- a/src/test/fixtures/mdz/component_simple/expected.json +++ b/src/test/fixtures/mdz/component_simple/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_space_before_closing/expected.json b/src/test/fixtures/mdz/component_space_before_closing/expected.json index 6d44191..a1e4c79 100644 --- a/src/test/fixtures/mdz/component_space_before_closing/expected.json +++ b/src/test/fixtures/mdz/component_space_before_closing/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/component_with_formatting/expected.json b/src/test/fixtures/mdz/component_with_formatting/expected.json index bc6b713..5bace1a 100644 --- a/src/test/fixtures/mdz/component_with_formatting/expected.json +++ b/src/test/fixtures/mdz/component_with_formatting/expected.json @@ -2,6 +2,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_bold_crossing_closer/expected.json b/src/test/fixtures/mdz/element_bold_crossing_closer/expected.json index fbdb714..4a60190 100644 --- a/src/test/fixtures/mdz/element_bold_crossing_closer/expected.json +++ b/src/test/fixtures/mdz/element_bold_crossing_closer/expected.json @@ -5,6 +5,7 @@ { "type": "Element", "name": "b", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_code_crossing_closer/expected.json b/src/test/fixtures/mdz/element_code_crossing_closer/expected.json index 25b563c..b1a4168 100644 --- a/src/test/fixtures/mdz/element_code_crossing_closer/expected.json +++ b/src/test/fixtures/mdz/element_code_crossing_closer/expected.json @@ -5,6 +5,7 @@ { "type": "Element", "name": "b", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_div/expected.json b/src/test/fixtures/mdz/element_div/expected.json index 3f760ba..f477935 100644 --- a/src/test/fixtures/mdz/element_div/expected.json +++ b/src/test/fixtures/mdz/element_div/expected.json @@ -2,6 +2,7 @@ { "type": "Element", "name": "div", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_empty/expected.json b/src/test/fixtures/mdz/element_empty/expected.json index 24614b4..2fa3751 100644 --- a/src/test/fixtures/mdz/element_empty/expected.json +++ b/src/test/fixtures/mdz/element_empty/expected.json @@ -2,6 +2,7 @@ { "type": "Element", "name": "div", + "attributes": [], "children": [], "start": 0, "end": 11 diff --git a/src/test/fixtures/mdz/element_interleaved_overlap/expected.json b/src/test/fixtures/mdz/element_interleaved_overlap/expected.json index fb741f3..ce0c921 100644 --- a/src/test/fixtures/mdz/element_interleaved_overlap/expected.json +++ b/src/test/fixtures/mdz/element_interleaved_overlap/expected.json @@ -5,6 +5,7 @@ { "type": "Element", "name": "b", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_multiline/expected.json b/src/test/fixtures/mdz/element_multiline/expected.json index c363a39..2d6eb5a 100644 --- a/src/test/fixtures/mdz/element_multiline/expected.json +++ b/src/test/fixtures/mdz/element_multiline/expected.json @@ -2,6 +2,7 @@ { "type": "Element", "name": "div", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_nested_same_name/expected.json b/src/test/fixtures/mdz/element_nested_same_name/expected.json index bd0b23e..cf717da 100644 --- a/src/test/fixtures/mdz/element_nested_same_name/expected.json +++ b/src/test/fixtures/mdz/element_nested_same_name/expected.json @@ -2,6 +2,7 @@ { "type": "Element", "name": "b", + "attributes": [], "children": [ { "type": "Text", @@ -12,6 +13,7 @@ { "type": "Element", "name": "b", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_nested_same_name_unclosed/expected.json b/src/test/fixtures/mdz/element_nested_same_name_unclosed/expected.json index a016af2..f2a5e6d 100644 --- a/src/test/fixtures/mdz/element_nested_same_name_unclosed/expected.json +++ b/src/test/fixtures/mdz/element_nested_same_name_unclosed/expected.json @@ -11,6 +11,7 @@ { "type": "Element", "name": "b", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_span/expected.json b/src/test/fixtures/mdz/element_span/expected.json index 0de0525..e126679 100644 --- a/src/test/fixtures/mdz/element_span/expected.json +++ b/src/test/fixtures/mdz/element_span/expected.json @@ -11,6 +11,7 @@ { "type": "Element", "name": "span", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/element_void/expected.json b/src/test/fixtures/mdz/element_void/expected.json index 7666904..4cf67a7 100644 --- a/src/test/fixtures/mdz/element_void/expected.json +++ b/src/test/fixtures/mdz/element_void/expected.json @@ -11,6 +11,7 @@ { "type": "Element", "name": "br", + "attributes": [], "children": [], "start": 4, "end": 10 @@ -28,6 +29,7 @@ { "type": "Element", "name": "br", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/heading_with_tag/expected.json b/src/test/fixtures/mdz/heading_with_tag/expected.json index 2c53de9..6279641 100644 --- a/src/test/fixtures/mdz/heading_with_tag/expected.json +++ b/src/test/fixtures/mdz/heading_with_tag/expected.json @@ -13,6 +13,7 @@ { "type": "Component", "name": "Alert", + "attributes": [], "children": [ { "type": "Text", diff --git a/src/test/fixtures/mdz/tag_attribute_bare_boolean/expected.json b/src/test/fixtures/mdz/tag_attribute_bare_boolean/expected.json new file mode 100644 index 0000000..69df203 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_bare_boolean/expected.json @@ -0,0 +1,59 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Element", + "name": "span", + "attributes": [ + { + "name": "hidden", + "value": true, + "start": 6, + "end": 12 + } + ], + "children": [ + { + "type": "Text", + "content": "x", + "start": 13, + "end": 14 + } + ], + "start": 0, + "end": 21 + }, + { + "type": "Text", + "content": " and ", + "start": 21, + "end": 26 + }, + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "dismissible", + "value": true, + "start": 33, + "end": 44 + } + ], + "children": [ + { + "type": "Text", + "content": "y", + "start": 45, + "end": 46 + } + ], + "start": 26, + "end": 54 + } + ], + "start": 0, + "end": 54 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_bare_boolean/input.mdz b/src/test/fixtures/mdz/tag_attribute_bare_boolean/input.mdz new file mode 100644 index 0000000..8ce77b9 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_bare_boolean/input.mdz @@ -0,0 +1 @@ + and y \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_element_and_component/expected.json b/src/test/fixtures/mdz/tag_attribute_element_and_component/expected.json new file mode 100644 index 0000000..4906bfc --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_element_and_component/expected.json @@ -0,0 +1,46 @@ +[ + { + "type": "Element", + "name": "div", + "attributes": [ + { + "name": "class", + "value": "box", + "start": 5, + "end": 16 + } + ], + "children": [ + { + "type": "Text", + "content": "a", + "start": 17, + "end": 18 + } + ], + "start": 0, + "end": 24 + }, + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "status", + "value": "error", + "start": 33, + "end": 47 + } + ], + "children": [ + { + "type": "Text", + "content": "b", + "start": 48, + "end": 49 + } + ], + "start": 26, + "end": 57 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_element_and_component/input.mdz b/src/test/fixtures/mdz/tag_attribute_element_and_component/input.mdz new file mode 100644 index 0000000..6811d9e --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_element_and_component/input.mdz @@ -0,0 +1,3 @@ +
a
+ +b \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_empty_value/expected.json b/src/test/fixtures/mdz/tag_attribute_empty_value/expected.json new file mode 100644 index 0000000..84ecba7 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_empty_value/expected.json @@ -0,0 +1,24 @@ +[ + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "title", + "value": "", + "start": 7, + "end": 15 + } + ], + "children": [ + { + "type": "Text", + "content": "x", + "start": 16, + "end": 17 + } + ], + "start": 0, + "end": 25 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_empty_value/input.mdz b/src/test/fixtures/mdz/tag_attribute_empty_value/input.mdz new file mode 100644 index 0000000..d704295 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_empty_value/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_gt_in_value/expected.json b/src/test/fixtures/mdz/tag_attribute_gt_in_value/expected.json new file mode 100644 index 0000000..0fa4bfb --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_gt_in_value/expected.json @@ -0,0 +1,24 @@ +[ + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "title", + "value": "a > b", + "start": 7, + "end": 20 + } + ], + "children": [ + { + "type": "Text", + "content": "x", + "start": 21, + "end": 22 + } + ], + "start": 0, + "end": 30 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_gt_in_value/input.mdz b/src/test/fixtures/mdz/tag_attribute_gt_in_value/input.mdz new file mode 100644 index 0000000..adf98f2 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_gt_in_value/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_brace/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_brace/expected.json new file mode 100644 index 0000000..32e228b --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_brace/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 26 + } + ], + "start": 0, + "end": 26 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_brace/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_brace/input.mdz new file mode 100644 index 0000000..3536444 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_brace/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_dangling_equals/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_dangling_equals/expected.json new file mode 100644 index 0000000..88add31 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_dangling_equals/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 19 + } + ], + "start": 0, + "end": 19 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_dangling_equals/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_dangling_equals/input.mdz new file mode 100644 index 0000000..a05ca20 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_dangling_equals/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_digit_name/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_digit_name/expected.json new file mode 100644 index 0000000..acbfbfb --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_digit_name/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 23 + } + ], + "start": 0, + "end": 23 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_digit_name/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_digit_name/input.mdz new file mode 100644 index 0000000..96faa06 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_digit_name/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_duplicate/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_duplicate/expected.json new file mode 100644 index 0000000..075cc2a --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_duplicate/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 28 + } + ], + "start": 0, + "end": 28 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_duplicate/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_duplicate/input.mdz new file mode 100644 index 0000000..e83d755 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_duplicate/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_missing_separator/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_missing_separator/expected.json new file mode 100644 index 0000000..291e105 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_missing_separator/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "
x", + "start": 0, + "end": 19 + } + ], + "start": 0, + "end": 19 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_missing_separator/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_missing_separator/input.mdz new file mode 100644 index 0000000..8e9d6e0 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_missing_separator/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_newline_in_tag/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_newline_in_tag/expected.json new file mode 100644 index 0000000..d251bf7 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_newline_in_tag/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 22 + } + ], + "start": 0, + "end": 22 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_newline_in_tag/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_newline_in_tag/input.mdz new file mode 100644 index 0000000..066908a --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_newline_in_tag/input.mdz @@ -0,0 +1,2 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_spaced_equals/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_spaced_equals/expected.json new file mode 100644 index 0000000..d8466d5 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_spaced_equals/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 24 + } + ], + "start": 0, + "end": 24 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_spaced_equals/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_spaced_equals/input.mdz new file mode 100644 index 0000000..b98579e --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_spaced_equals/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_tab_separator/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_tab_separator/expected.json new file mode 100644 index 0000000..6880717 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_tab_separator/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 22 + } + ], + "start": 0, + "end": 22 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_tab_separator/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_tab_separator/input.mdz new file mode 100644 index 0000000..05edae0 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_tab_separator/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_unquoted/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_unquoted/expected.json new file mode 100644 index 0000000..7757965 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_unquoted/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 20 + } + ], + "start": 0, + "end": 20 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_unquoted/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_unquoted/input.mdz new file mode 100644 index 0000000..e1ff2d1 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_unquoted/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_unterminated_quote/expected.json b/src/test/fixtures/mdz/tag_attribute_invalid_unterminated_quote/expected.json new file mode 100644 index 0000000..ff6dcb1 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_unterminated_quote/expected.json @@ -0,0 +1,15 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "x", + "start": 0, + "end": 21 + } + ], + "start": 0, + "end": 21 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_invalid_unterminated_quote/input.mdz b/src/test/fixtures/mdz/tag_attribute_invalid_unterminated_quote/input.mdz new file mode 100644 index 0000000..86d6a64 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_invalid_unterminated_quote/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_self_close_no_space/expected.json b/src/test/fixtures/mdz/tag_attribute_self_close_no_space/expected.json new file mode 100644 index 0000000..3f5f7a2 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_self_close_no_space/expected.json @@ -0,0 +1,17 @@ +[ + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "disabled", + "value": true, + "start": 7, + "end": 15 + } + ], + "children": [], + "start": 0, + "end": 17 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_self_close_no_space/input.mdz b/src/test/fixtures/mdz/tag_attribute_self_close_no_space/input.mdz new file mode 100644 index 0000000..ddb8c6c --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_self_close_no_space/input.mdz @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_self_close_with_space/expected.json b/src/test/fixtures/mdz/tag_attribute_self_close_with_space/expected.json new file mode 100644 index 0000000..ae5874b --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_self_close_with_space/expected.json @@ -0,0 +1,17 @@ +[ + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "disabled", + "value": true, + "start": 7, + "end": 15 + } + ], + "children": [], + "start": 0, + "end": 18 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_self_close_with_space/input.mdz b/src/test/fixtures/mdz/tag_attribute_self_close_with_space/input.mdz new file mode 100644 index 0000000..88ae026 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_self_close_with_space/input.mdz @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_string_double/expected.json b/src/test/fixtures/mdz/tag_attribute_string_double/expected.json new file mode 100644 index 0000000..ffb12c1 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_string_double/expected.json @@ -0,0 +1,24 @@ +[ + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "status", + "value": "error", + "start": 7, + "end": 21 + } + ], + "children": [ + { + "type": "Text", + "content": "x", + "start": 22, + "end": 23 + } + ], + "start": 0, + "end": 31 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_string_double/input.mdz b/src/test/fixtures/mdz/tag_attribute_string_double/input.mdz new file mode 100644 index 0000000..0c56abb --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_string_double/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/mdz/tag_attribute_string_single/expected.json b/src/test/fixtures/mdz/tag_attribute_string_single/expected.json new file mode 100644 index 0000000..ffb12c1 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_string_single/expected.json @@ -0,0 +1,24 @@ +[ + { + "type": "Component", + "name": "Alert", + "attributes": [ + { + "name": "status", + "value": "error", + "start": 7, + "end": 21 + } + ], + "children": [ + { + "type": "Text", + "content": "x", + "start": 22, + "end": 23 + } + ], + "start": 0, + "end": 31 + } +] diff --git a/src/test/fixtures/mdz/tag_attribute_string_single/input.mdz b/src/test/fixtures/mdz/tag_attribute_string_single/input.mdz new file mode 100644 index 0000000..62e6cd6 --- /dev/null +++ b/src/test/fixtures/mdz/tag_attribute_string_single/input.mdz @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_component_props/expected.json b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_component_props/expected.json new file mode 100644 index 0000000..69f6839 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_component_props/expected.json @@ -0,0 +1,3 @@ +{ + "code": "\n\nhi\n" +} diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_component_props/input.svelte b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_component_props/input.svelte new file mode 100644 index 0000000..7700c1d --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_component_props/input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_allowed/expected.json b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_allowed/expected.json new file mode 100644 index 0000000..3847a37 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_allowed/expected.json @@ -0,0 +1,3 @@ +{ + "code": "\n\n\n" +} diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_allowed/input.svelte b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_allowed/input.svelte new file mode 100644 index 0000000..ac9fc61 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_allowed/input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_bare_boolean/expected.json b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_bare_boolean/expected.json new file mode 100644 index 0000000..d7558e6 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_bare_boolean/expected.json @@ -0,0 +1,3 @@ +{ + "code": "\n\n\n" +} diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_bare_boolean/input.svelte b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_bare_boolean/input.svelte new file mode 100644 index 0000000..8a54c0d --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_bare_boolean/input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_dropped/expected.json b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_dropped/expected.json new file mode 100644 index 0000000..3847a37 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_dropped/expected.json @@ -0,0 +1,3 @@ +{ + "code": "\n\n\n" +} diff --git a/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_dropped/input.svelte b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_dropped/input.svelte new file mode 100644 index 0000000..c462697 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/tag_attribute_element_dropped/input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/test/mdz_helpers.test.ts b/src/test/mdz_helpers.test.ts index fc3d438..27814a7 100644 --- a/src/test/mdz_helpers.test.ts +++ b/src/test/mdz_helpers.test.ts @@ -1,6 +1,6 @@ import {test, assert, describe} from 'vitest'; -import type {MdzNode, MdzNodeComponent, MdzNodeElement} from '$lib/mdz.ts'; +import type {MdzAttribute, MdzNode, MdzNodeComponent, MdzNodeElement} from '$lib/mdz.ts'; import { is_letter, is_tag_name_char, @@ -14,6 +14,9 @@ import { mdz_is_url, mdz_is_safe_reference, mdz_is_void_element, + ALLOWED_ELEMENT_ATTRIBUTES, + mdz_filter_element_attributes, + mdz_format_unregistered_attributes, match_url_prefix_case_insensitive, ascii_to_lower, mdz_resolve_relative_path, @@ -57,6 +60,7 @@ const link = (reference: string, children: Array): MdzNode => ({ const component = (name: string, children: Array): MdzNodeComponent => ({ type: 'Component', name, + attributes: [], children, start: 0, end: 0, @@ -64,6 +68,7 @@ const component = (name: string, children: Array): MdzNodeComponent => const element = (name: string, children: Array): MdzNodeElement => ({ type: 'Element', name, + attributes: [], children, start: 0, end: 0, @@ -766,3 +771,92 @@ describe('mdz_extract_single_tag', () => { assert.equal(mdz_extract_single_tag([text(' '), text('\n')]), null); }); }); + +// helper to create attributes with dummy positions +const attr = (name: string, value: string | true = true): MdzAttribute => ({ + name, + value, + start: 0, + end: 0, +}); + +describe('mdz_filter_element_attributes', () => { + test('passes through allowed attribute names', () => { + assert.deepEqual( + mdz_filter_element_attributes( + 'div', + [attr('class', 'box'), attr('title', 'hi')], + ALLOWED_ELEMENT_ATTRIBUTES, + ), + {class: 'box', title: 'hi'}, + ); + }); + + test('passes through an allowed aria attribute', () => { + assert.deepEqual( + mdz_filter_element_attributes( + 'span', + [attr('aria-label', 'close')], + ALLOWED_ELEMENT_ATTRIBUTES, + ), + {'aria-label': 'close'}, + ); + }); + + test('drops disallowed attribute names', () => { + assert.deepEqual( + mdz_filter_element_attributes( + 'div', + [attr('class', 'box'), attr('onclick', 'x'), attr('id', 'y')], + ALLOWED_ELEMENT_ATTRIBUTES, + ), + {class: 'box'}, + ); + }); + + test('preserves a boolean `true` value', () => { + assert.deepEqual( + mdz_filter_element_attributes( + 'span', + [attr('aria-hidden', true)], + ALLOWED_ELEMENT_ATTRIBUTES, + ), + {'aria-hidden': true}, + ); + }); + + test('returns an empty object for empty input', () => { + assert.deepEqual(mdz_filter_element_attributes('div', [], ALLOWED_ELEMENT_ATTRIBUTES), {}); + }); +}); + +describe('mdz_format_unregistered_attributes', () => { + test('formats a string-valued attribute', () => { + assert.equal(mdz_format_unregistered_attributes([attr('status', 'error')]), ' status="error"'); + }); + + test('formats a bare boolean attribute', () => { + assert.equal(mdz_format_unregistered_attributes([attr('disabled', true)]), ' disabled'); + }); + + test('formats a mix of string and boolean attributes', () => { + assert.equal( + mdz_format_unregistered_attributes([attr('status', 'error'), attr('disabled', true)]), + ' status="error" disabled', + ); + }); + + test('returns an empty string for empty input', () => { + assert.equal(mdz_format_unregistered_attributes([]), ''); + }); + + test('uses single quotes for a value containing a double quote', () => { + // a `"`-containing value must have been single-quoted at the source + // (a `"`-delimited value can't hold `"`), so the placeholder stays balanced + assert.equal(mdz_format_unregistered_attributes([attr('title', 'a"b')]), " title='a\"b'"); + }); + + test('uses double quotes for a value containing a single quote', () => { + assert.equal(mdz_format_unregistered_attributes([attr('title', "a'b")]), ` title="a'b"`); + }); +}); diff --git a/src/test/mdz_nesting_cap.test.ts b/src/test/mdz_nesting_cap.test.ts index 4aaff57..a2dbd01 100644 --- a/src/test/mdz_nesting_cap.test.ts +++ b/src/test/mdz_nesting_cap.test.ts @@ -94,6 +94,12 @@ const BATTERY: Array<[string, string]> = [ ['[b](/u)', 'Paragraph("" Link:/u("b"))'], ['[b](/u)', 'Paragraph("[" Element:a("b](/u)"))'], ['``x', 'Paragraph(`` "x")'], + // attribute-bearing tags — attributes ride the open tag without perturbing + // nesting/scoping; a `>` inside a quoted value is content, and a failed tag's + // attributes survive in the literal-text fallback + ['xy', 'Element:a("x" Element:a("y"))'], + ['_x_', 'Element:a(Italic("x"))'], + ['_xy_z', 'Paragraph("_x" Element:a("y") "_z")'], // table cell delegates to the same sync lexer [ '| _xy_z | b |\n| --- | --- |\n| c | d |\n', @@ -148,6 +154,33 @@ describe('inline nesting-depth cap', () => { } }); + test('attributes do not perturb the nesting-depth cap', () => { + // attribute-bearing tags nest and cap exactly like bare tags — the cap + // counts containers, not attribute text (the closer stays attribute-free) + const open = ''; + assert.strictEqual( + max_container_depth(mdz_parse(open.repeat(CAP) + 'X' + ''.repeat(CAP))), + CAP, + ); + assert.strictEqual( + max_container_depth(mdz_parse(open.repeat(CAP + 1) + 'X' + ''.repeat(CAP + 1))), + CAP, + ); + // sync/stream parity at and beyond the cap, one-shot and chunked + for (const n of [CAP - 1, CAP, CAP + 1, CAP * 2]) { + const input = open.repeat(n) + 'X' + ''.repeat(n); + const expected = JSON.stringify(mdz_parse(input)); + assert.strictEqual(JSON.stringify(stream_parse(input)), expected, `parity at n=${n}`); + for (const chunk of [1, 3, 64]) { + assert.strictEqual( + JSON.stringify(stream_parse_chunked(input, chunk)), + expected, + `chunk=${chunk} n=${n}`, + ); + } + } + }); + test('the cap is per-run — a nest past it does not suppress a later run', () => { // depth resets when the paragraph closes: the second paragraph's link // still forms even though the first blew past the cap diff --git a/src/test/mdz_parser_parity.test.ts b/src/test/mdz_parser_parity.test.ts index b7c8a98..eb58d5e 100644 --- a/src/test/mdz_parser_parity.test.ts +++ b/src/test/mdz_parser_parity.test.ts @@ -767,6 +767,37 @@ describe('mdz parser parity', () => { } }); + describe('tag attribute chunking', () => { + // attributes ride the open tag, so the same open/close decisions must hold + // across every chunk boundary — a value streamed a char at a time resolves + // to the same tree as the one-shot parse, and every invalid form (brace + // value, duplicate name, unterminated quote) reverts to literal text + // identically. The ``-in-value case proves the quoted value is consumed + // atomically: the real closer is the final ``, not the one in the value. + for (const input of [ + 'x', + "x", + '', + 'x', + 'x', + 'x', + '', + '', + 'x', // invalid brace value → literal + 'z', // duplicate name → literal + 'y', // `` inside the value is content, not the closer + ]) { + test(`one-shot matches mdz_parse for ${JSON.stringify(input)}`, () => { + assert.deepEqual(stream_parse_text(input), mdz_parse(input)); + }); + for (const chunk_size of [1, 2, 3]) { + test(`chunk_size=${chunk_size} matches mdz_parse for ${JSON.stringify(input)}`, () => { + assert.deepEqual(stream_parse_chunked(input, chunk_size), mdz_parse(input)); + }); + } + } + }); + describe('mid-stack revert ordering', () => { // a failed-closer revert dissolves a mid-stack frame while optimistic // containers above it stay open — the replacement delimiter text must @@ -879,6 +910,14 @@ describe('mdz parser parity', () => { 'x', 'a a', + // tag attributes: string/bare values, `>`-in-value, duplicate revert, + // and a ``-shaped substring inside a value (atomic consumption — + // the real closer is the final ``, not the one in the value) + 'x', + '', + 'y', + 'z', + 'y', // doubled-delimiter and italic-closer holds 'a~x~~y~~z', '_a_x', diff --git a/src/test/mdz_render.test.ts b/src/test/mdz_render.test.ts index 5fd7590..7ab4d7c 100644 --- a/src/test/mdz_render.test.ts +++ b/src/test/mdz_render.test.ts @@ -6,17 +6,28 @@ * `svelte/server`'s `render` (no DOM environment needed). */ -import {describe, test, assert} from 'vitest'; +import {describe, test, assert, vi} from 'vitest'; import {render} from 'svelte/server'; import type {Component} from 'svelte'; +import {DEV} from 'esm-env'; import MdzComponent from '$lib/Mdz.svelte'; +import type {MdzComponents, MdzElements} from '$lib/mdz_contexts.ts'; +import MdzRenderHarnessComponent from './MdzRenderHarness.svelte'; +import EchoProp from './EchoProp.svelte'; import {load_fixtures} from './fixtures/mdz/mdz_test_helpers.ts'; // narrowed component type — the wrapper's `SvelteHTMLElements` rest-prop // union is too complex for `render()`'s generic inference const Mdz = MdzComponent as unknown as Component<{content: string}>; +// harness that provides the `elements`/`components` contexts around `Mdz` +const Harness = MdzRenderHarnessComponent as unknown as Component<{ + content: string; + elements?: MdzElements; + components?: MdzComponents; +}>; + describe('Mdz SSR rendering', () => { test('renders simple content', () => { const result = render(Mdz, {props: {content: 'hello **bold** and `code`'}}); @@ -34,3 +45,89 @@ describe('Mdz SSR rendering', () => { } }); }); + +describe('tag attributes', () => { + test('renders an allowed element attribute', () => { + const result = render(Harness, { + props: {content: '', elements: new Map([['aside', true]])}, + }); + assert.include(result.body, 'class="x"'); + assert.include(result.body, 'hi'); + }); + + test('an element registered with `false` falls back to a literal placeholder', () => { + // only `true` enables an element — a `false` value is treated as + // unregistered, so the tag renders as source-faithful literal text + // (attributes included) rather than a real `', {}, {aside: true}); + assert.equal(result.markup, ``); + }); + + test('drops a disallowed element attribute', () => { + const result = convert('', {}, {aside: true}); + assert.equal(result.markup, ``); + assert.notInclude(result.markup, 'onclick'); + }); + + test('emits a bare boolean element attribute', () => { + const result = convert('', {}, {aside: true}); + assert.equal(result.markup, ``); + }); + + test('passes component attributes through as props (string + bare boolean)', () => { + const result = convert('hi', { + Alert: '$lib/Alert.svelte', + }); + assert.equal(result.markup, `hi`); + assert_import(result, 'Alert', '$lib/Alert.svelte', 'default'); + }); + + test('escapes a string value through escape_js_string', () => { + // a value with a single-quote and a backslash must be escaped for the + // single-quoted JS-expression attribute (not emitted verbatim) + const value = "a'b\\c"; + const result = convert(`hi`, {Alert: '$lib/Alert.svelte'}); + assert.equal(result.markup, `hi`); + // the raw quote/backslash never appear unescaped in the output + assert.include(result.markup, escape_js_string(value)); + }); + }); });