Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 | `<Alert>…</Alert>` / `<aside>…</aside>` (must be registered) |
| Components / elements | `<Alert status="error">…</Alert>` / `<aside class="box">…</aside>` (must be registered); attributes are quoted strings (`"` or `'`) or bare booleans (`<input disabled />`); **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; `<br />` (registered) for hard breaks |

Whitespace follows standard markdown semantics: the parser preserves
Expand Down Expand Up @@ -252,21 +253,25 @@ 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.

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

Expand Down Expand Up @@ -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)
30 changes: 30 additions & 0 deletions src/benchmarks/benchmark_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,4 +312,34 @@ Functions with Array<string>, Promise<void>, and Map<string, number> in prose.
(_, i) => '['.repeat(200) + `text ${i} ` + '<a>'.repeat(200) + `X${i}` + '</a>'.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) =>
`<Callout status="ok" dismissible title="row ${i}">Section ${i} with <Badge kind="info" small>**${i}**</Badge>.</Callout>`,
).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:
'<Callout ' + Array.from({length: 4000}, (_, i) => `a${i}="${i}"`).join(' ') + '>x</Callout>',
},
{
name: 'hold line attribute value',
// One giant unterminated quoted attribute value (`<a x="yyyy…`). The
// streaming parser holds inside the value while it grows, re-entering the
// tag scan every feed; the closing-quote/newline search must be memoized
// (`buffer_index_of`) or chunked feeds go quadratic in the value length.
// The attribute analog of `hold line code`.
content: '<a x="' + 'y'.repeat(16_000),
},
];
44 changes: 31 additions & 13 deletions src/lib/MdzNodeView.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
<script lang="ts">
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,
Expand Down Expand Up @@ -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)}
<!-- void elements cannot have content — any parsed children are dropped -->
<svelte:element this={node.name} />
<svelte:element this={node.name} {...attrs} />
{:else}
<svelte:element this={node.name}>
<svelte:element this={node.name} {...attrs}>
{#if node.children.length > 0}
{@render render_children(node.children)}
{/if}
</svelte:element>
{/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}
<Component>
{@const props = mdz_attributes_to_props(node.attributes)}
<Component {...props}>
{#if node.children.length > 0}
{@render render_children(node.children)}
{/if}
</Component>
{: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}
Expand Down Expand Up @@ -164,12 +177,17 @@ tree instead of per node. -->
{/each}
{/snippet}

{#snippet render_unregistered_tag(name: string, children: Array<MdzNode>)}
{#snippet render_unregistered_tag(
name: string,
attributes: Array<MdzAttribute>,
children: Array<MdzNode>,
)}
{@const formatted = mdz_format_unregistered_attributes(attributes)}
{#if children.length > 0}
<code class="color_c_50">&lt;{name}&gt;</code>{@render render_children(children)}<code
class="color_c_50">&lt;/{name}&gt;</code
>
<code class="color_c_50">&lt;{name}{formatted}&gt;</code>{@render render_children(
children,
)}<code class="color_c_50">&lt;/{name}&gt;</code>
{:else}
<code class="color_c_50">&lt;{name} /&gt;</code>
<code class="color_c_50">&lt;{name}{formatted} /&gt;</code>
{/if}
{/snippet}
44 changes: 31 additions & 13 deletions src/lib/MdzStreamNodeView.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
<script lang="ts">
import {resolve} from '$app/paths';

import {mdz_classify_link, mdz_is_void_element} from './mdz_helpers.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,
Expand All @@ -10,7 +17,7 @@
mdz_codeblock_context,
} from './mdz_contexts.ts';
import type {MdzStreamNode} from './mdz_stream_state.svelte.ts';
import type {MdzTableAlign} from './mdz.ts';
import type {MdzAttribute, MdzTableAlign} from './mdz.ts';

const {
node,
Expand Down Expand Up @@ -47,30 +54,36 @@ tree instead of per node. -->
{#snippet render_node(node: MdzStreamNode)}
{#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 ?? '')}
<!-- void elements cannot have content — any parsed children are dropped -->
<svelte:element this={node.name} />
<svelte:element this={node.name} {...attrs} />
{:else}
<svelte:element this={node.name}>
<svelte:element this={node.name} {...attrs}>
{#if node.children.length > 0}
{@render render_children(node.children)}
{/if}
</svelte:element>
{/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}
<Component>
{@const props = mdz_attributes_to_props(node.attributes ?? [])}
<Component {...props}>
{#if node.children.length > 0}
{@render render_children(node.children)}
{/if}
</Component>
{: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}
Expand Down Expand Up @@ -173,12 +186,17 @@ tree instead of per node. -->
{/each}
{/snippet}

{#snippet render_unregistered_tag(name: string, children: Array<MdzStreamNode>)}
{#snippet render_unregistered_tag(
name: string,
attributes: Array<MdzAttribute>,
children: Array<MdzStreamNode>,
)}
{@const formatted = mdz_format_unregistered_attributes(attributes)}
{#if children.length > 0}
<code class="color_c_50">&lt;{name}&gt;</code>{@render render_children(children)}<code
class="color_c_50">&lt;/{name}&gt;</code
>
<code class="color_c_50">&lt;{name}{formatted}&gt;</code>{@render render_children(
children,
)}<code class="color_c_50">&lt;/{name}&gt;</code>
{:else}
<code class="color_c_50">&lt;{name} /&gt;</code>
<code class="color_c_50">&lt;{name}{formatted} /&gt;</code>
{/if}
{/snippet}
14 changes: 13 additions & 1 deletion src/lib/mdz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
* ## Status
*
* Pre-stable — breaking changes are expected as the dialect matures.
* Next: attributes, editing.
* Next: editing.
*
* @module
*/
Expand Down Expand Up @@ -251,16 +251,28 @@ export interface MdzNodeTableCell extends MdzNodeBase {
children: Array<MdzNode>;
}

export interface MdzAttribute {
name: string;
/** `true` for bare boolean shorthand (`disabled` ≡ `disabled={true}`). */
value: string | true;
start: number;
end: number;
}

export interface MdzNodeElement extends MdzNodeBase {
type: 'Element';
/** HTML element name (e.g. `div`, `span`, `aside`). */
name: string;
/** Parsed tag attributes; always present, possibly empty. */
attributes: Array<MdzAttribute>;
children: Array<MdzNode>;
}

export interface MdzNodeComponent extends MdzNodeBase {
type: 'Component';
/** Svelte component name (e.g. `Alert`, `Card`). */
name: string;
/** Parsed tag attributes; always present, possibly empty. */
attributes: Array<MdzAttribute>;
children: Array<MdzNode>;
}
13 changes: 8 additions & 5 deletions src/lib/mdz_contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ import {create_context} from './context_helpers.ts';
/**
* Component registry for custom Svelte components that can be used in mdz content.
*
* For example, registering 'Alert' allows using `<Alert>...</Alert>` in mdz content.
* For example, registering 'Alert' allows using `<Alert status="error">...</Alert>`
* in mdz content; parsed attributes pass through as props.
*
* The Map values are Svelte components.
*/
export type MdzComponents = Map<string, Component<any, any>>; // TODO support params
// props pass through untyped (`string | true`); a typed registry is a separate concern
export type MdzComponents = Map<string, Component<any, any>>;

/**
* Element registry for HTML elements that can be used in mdz content.
*
* For example, registering 'div' allows using `<div>...</div>` in mdz content.
*
* The Map values are boolean placeholders for future configuration options.
* Register an element by mapping its name to `true` (e.g. `['aside', true]`),
* which allows `<aside>...</aside>` in mdz content. Only `true` enables an
* element — the renderers treat any other value (including `false`) as
* unregistered, so the tag falls back to literal text.
*/
export type MdzElements = Map<string, boolean>;

Expand Down
Loading