@markii/html 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { INTERACTIVE_ATTRIBUTE } from '@markii/stdlib';
1
2
  const DEFAULT_TITLE = 'Details';
2
3
  /**
3
4
  * `:::details{title="..." open} ... :::` — a collapsible disclosure, folded
@@ -10,7 +11,9 @@ const DEFAULT_TITLE = 'Details';
10
11
  export const Details = (attributes, childrenHtml, ctx) => {
11
12
  const title = attributes.title ?? DEFAULT_TITLE;
12
13
  const open = Object.hasOwn(attributes, 'open');
14
+ // `@markii/stdlib`'s `INTERACTIVE_ATTRIBUTE` (#53), mirroring
15
+ // `@markii/react`'s `Details`.
13
16
  return (`<details class="mk-details"${open ? ' open' : ''}>` +
14
- `<summary class="mk-details__summary">${ctx.esc(title)}</summary>` +
17
+ `<summary class="mk-details__summary" ${INTERACTIVE_ATTRIBUTE}="">${ctx.esc(title)}</summary>` +
15
18
  `<div class="mk-details__body">${childrenHtml}</div></details>`);
16
19
  };
@@ -14,5 +14,11 @@ import type { HtmlComponent } from '../registry.js';
14
14
  * exact same allowlist check the sanitizer uses) and dropping the image
15
15
  * entirely when it fails, rather than re-implementing URL-scheme parsing
16
16
  * here. Matches `@markii/react`'s `Figure` markup byte-for-byte.
17
+ *
18
+ * `ctx.resolveImageSrc` (`../render.js`'s `renderMarkToHtml` option) then
19
+ * gets the same chance at an already-safe `src` that an ordinary markdown
20
+ * image gets (`../render.js`'s `applyImageResolver`), so a host resolving
21
+ * relative images sees this component's picture too, not just the ones
22
+ * markdown itself wrote.
17
23
  */
18
24
  export declare const Figure: HtmlComponent;
@@ -1,5 +1,9 @@
1
1
  import { isSafeUrl } from '@markii/core';
2
+ import { reportDiagnostic } from '@markii/stdlib';
3
+ import { resolveImageAttribute } from '../image-resolve.js';
4
+ import { unsafeImageSrcTitle } from '../failure-presentation.js';
2
5
  const DEFAULT_ALT = '';
6
+ const DIRECTIVE_NAME = 'figure';
3
7
  /**
4
8
  * `:::figure{src="..." alt="..."} caption markdown :::` — an image with a
5
9
  * rich (markdown) caption. `src` is required; a missing `src` renders no
@@ -15,14 +19,37 @@ const DEFAULT_ALT = '';
15
19
  * exact same allowlist check the sanitizer uses) and dropping the image
16
20
  * entirely when it fails, rather than re-implementing URL-scheme parsing
17
21
  * here. Matches `@markii/react`'s `Figure` markup byte-for-byte.
22
+ *
23
+ * `ctx.resolveImageSrc` (`../render.js`'s `renderMarkToHtml` option) then
24
+ * gets the same chance at an already-safe `src` that an ordinary markdown
25
+ * image gets (`../render.js`'s `applyImageResolver`), so a host resolving
26
+ * relative images sees this component's picture too, not just the ones
27
+ * markdown itself wrote.
18
28
  */
19
29
  export const Figure = (attributes, childrenHtml, ctx) => {
20
30
  const rawSrc = attributes.src ?? null;
21
31
  const alt = attributes.alt ?? DEFAULT_ALT;
22
- const src = rawSrc && isSafeUrl(rawSrc) ? rawSrc : null;
32
+ const refused = Boolean(rawSrc) && !isSafeUrl(rawSrc);
33
+ const safeSrc = rawSrc && !refused ? rawSrc : null;
34
+ const src = safeSrc
35
+ ? resolveImageAttribute(safeSrc, ctx.resolveImageSrc)
36
+ : null;
23
37
  const imgHtml = src
24
38
  ? `<img class="mk-figure__img" src="${ctx.esc(src)}" alt="${ctx.esc(alt)}">`
25
39
  : '';
26
- return (`<figure class="mk-figure">${imgHtml}` +
27
- `<figcaption class="mk-figure__caption">${childrenHtml}</figcaption></figure>`);
40
+ const figcaptionHtml = `<figcaption class="mk-figure__caption">${childrenHtml}</figcaption>`;
41
+ // AGENTS.md "clean is not silent": a refused `src` used to render a
42
+ // caption with no image and no explanation. Mirrors `@markii/react`'s
43
+ // `Figure`.
44
+ if (refused) {
45
+ const message = unsafeImageSrcTitle(DIRECTIVE_NAME);
46
+ reportDiagnostic(ctx.onDiagnostic, {
47
+ kind: 'unsafe-image-src',
48
+ directive: DIRECTIVE_NAME,
49
+ message,
50
+ });
51
+ return (`<figure class="mk-figure" data-mk-notice="" title="${ctx.esc(message)}">` +
52
+ `${imgHtml}${figcaptionHtml}</figure>`);
53
+ }
54
+ return `<figure class="mk-figure">${imgHtml}${figcaptionHtml}</figure>`;
28
55
  };
@@ -2,4 +2,4 @@
2
2
  // Regenerate with: node scripts/generate-doc-css.ts
3
3
  // Source of truth: packages/platforms/markii-react/src/doc.css
4
4
  /** The shared document stylesheet (@markii/react's doc.css), embedded as a string for exportHtmlDocument's <style> block. */
5
- export const DOC_CSS = "/*\n * Document rhythm: components own their insides only, never outer margins.\n * This single rule spaces every block-level child of `.doc` identically —\n * paragraphs, headings, and components alike — so new components always\n * sit correctly in the flow with zero per-component tuning.\n */\n.doc > * + * {\n margin-block-start: 1rem;\n}\n\n/*\n * ============================================================================\n * TIER 1 TOKENS — the theming contract\n * ============================================================================\n * Every host that wants a themed (in particular, a dark) `.doc` remaps ONLY\n * these custom properties, deliberately scoped to `.doc` and not `:root`:\n * `doc.css` is a library stylesheet embedded into other people's pages\n * (Obsidian injects it globally), so it must stay polite and never claim\n * `:root`. A host theme layer loaded after this one, at the same\n * specificity, simply redeclares the ones below on `.doc` (or a more\n * specific selector) to win the cascade — see `apps/vscode/src/webview/\n * theme.css` and `apps/obsidian/src/obsidian-theme.css`.\n *\n * Nine neutrals + five semantic hues. This list is deliberately small: a\n * host maps ~14 tokens instead of ~60 individual selectors, and a\n * derivation layer below (Tier 2) builds every finer shade FROM these, so\n * remapping these 14 makes every callout/badge/chart/etc. variant\n * theme-correct for free.\n */\n.doc {\n /* ---------- neutrals ---------- */\n --mk-bg: #fff; /* page ground */\n --mk-raised: #fff; /* raised surface for cards — distinct from ground; dark themes need real separation here where light themes get it from the border alone */\n --mk-fg: #1a1a1a; /* body text */\n --mk-surface: #f4f4f5; /* subtle fill one step off the ground: pre, kbd, script marker, details, default callout, zebra rows */\n --mk-surface-strong: #f0f0f2; /* second step off the ground: inline code, th, progress track, default badge background */\n --mk-border: #e4e4e7; /* hairlines */\n --mk-muted: #52525b; /* secondary text: captions, labels, script summary */\n --mk-faint: #94a3b8; /* tertiary/empty: missing values, empty-chart text, unfilled stars */\n --mk-accent: #3b82f6; /* the single interactive/brand color: active tab, progress bar, chart stroke/fill */\n --mk-on-accent: #fff; /* ink that sits ON a solid --mk-accent fill; a host must supply this because it cannot be derived: it depends on the accent's own lightness, not on the page palette */\n\n /* ---------- semantic hues ---------- */\n --mk-info: #3b82f6;\n --mk-success: #15803d;\n --mk-warning: #d97706;\n --mk-danger: #dc2626;\n --mk-limit: #7c3aed; /* the purple, used for the \"hit a resource limit\" failure kind */\n\n /* ---------- width presets (docs/format.md) ---------- */\n /*\n * The sizing half of the `width=`/`:::wide` etc. layout presets, as\n * hooks a host theme layer can override (AGENTS.md's \"New Tier 1 token\"\n * rule) — e.g. a host with a narrower reading column can shrink\n * `--mk-width-wide` without touching any selector below. `fit` and\n * `full` are exposed too even though their current values are a keyword\n * and `100%` respectively, so all four sized presets are equally\n * themeable; `normal` (the explicit default) produces no class and no\n * box to size, so it has no token — there is nothing for a host to hook.\n */\n --mk-width-fit: fit-content;\n --mk-width-narrow: 30rem;\n --mk-width-wide: 64rem;\n --mk-width-full: 100%;\n}\n\n/*\n * ============================================================================\n * DARK MODE (exported documents only) + the data-mk-theme override\n * ============================================================================\n * `@markii/html`'s `exportHtmlDocument` produces a standalone page with no\n * host page around it, so this is the one place `doc.css` picks a dark\n * palette for itself, guarded by `prefers-color-scheme: dark` rather than a\n * class, since a standalone file has nothing to put a class on.\n *\n * This does not touch how a HOST theme layer works. `apps/vscode/src/\n * webview/theme.css` and `apps/obsidian/src/obsidian-theme.css` are loaded\n * AFTER `doc.css` and redeclare these same tokens on a plain `.doc`\n * selector, unconditionally. At equal specificity the later, unconditional\n * rule always wins the cascade over this earlier, media-guarded one,\n * regardless of the reader's OS preference, so a themed host's own\n * light/dark handling is unaffected either way. Only a page with no such\n * layer after it, an exported document, ever resolves these.\n *\n * A viewer with no preference at all matches neither `light` nor `dark`\n * media features, so the Tier 1 defaults above stay exactly as they were.\n *\n * `data-mk-theme` lets a page or a host that manages its OWN theme opt out\n * of, or force, this automatic palette: `data-mk-theme=\"light\"` keeps the\n * light Tier 1 defaults no matter what the OS prefers, and\n * `data-mk-theme=\"dark\"` applies the dark palette unconditionally, outside\n * any media query. Both are attribute selectors on `.doc`\n * (specificity 0,2,0), deliberately higher than the plain `.doc` this\n * media block uses (0,1,0) and higher than the host theme layers above, so\n * an explicit attribute always wins over both the OS preference and a\n * host's own remap. The media block above is left at plain `.doc` on\n * purpose, NOT `.doc:not([data-mk-theme=\"light\"])`: raising ITS specificity\n * to (0,2,0) would make it beat the host theme layers too (which redeclare\n * on plain `.doc` and rely on winning by being later in the cascade at\n * equal specificity), breaking both hosts on a dark-preference machine. A\n * host that never sets `data-mk-theme` sees no change at all; the two\n * attribute values are an opt-in a page reaches for deliberately.\n *\n * The dark values below are declared twice on purpose (media-guarded and\n * forced), because `@media` cannot wrap only part of a selector list and\n * the forced path must apply even when the OS prefers light. `doc-css-\n * tokens.test.ts` asserts the two blocks carry identical values so they\n * cannot drift apart under a one-sided edit.\n */\n@media (prefers-color-scheme: dark) {\n .doc {\n --mk-bg: #14161c;\n --mk-raised: #1c1f28;\n --mk-fg: #e5e7eb;\n --mk-surface: #1e212b;\n --mk-surface-strong: #262a36;\n --mk-border: #333846;\n --mk-muted: #a1a8ba;\n --mk-faint: #6b7280;\n --mk-accent: #60a5fa;\n --mk-on-accent: #0b1220;\n\n --mk-info: #60a5fa;\n --mk-success: #4ade80;\n --mk-warning: #fbbf24;\n --mk-danger: #f87171;\n --mk-limit: #a78bfa;\n }\n}\n\n.doc[data-mk-theme='light'] {\n --mk-bg: #fff;\n --mk-raised: #fff;\n --mk-fg: #1a1a1a;\n --mk-surface: #f4f4f5;\n --mk-surface-strong: #f0f0f2;\n --mk-border: #e4e4e7;\n --mk-muted: #52525b;\n --mk-faint: #94a3b8;\n --mk-accent: #3b82f6;\n --mk-on-accent: #fff;\n\n --mk-info: #3b82f6;\n --mk-success: #15803d;\n --mk-warning: #d97706;\n --mk-danger: #dc2626;\n --mk-limit: #7c3aed;\n}\n\n.doc[data-mk-theme='dark'] {\n --mk-bg: #14161c;\n --mk-raised: #1c1f28;\n --mk-fg: #e5e7eb;\n --mk-surface: #1e212b;\n --mk-surface-strong: #262a36;\n --mk-border: #333846;\n --mk-muted: #a1a8ba;\n --mk-faint: #6b7280;\n --mk-accent: #60a5fa;\n --mk-on-accent: #0b1220;\n\n --mk-info: #60a5fa;\n --mk-success: #4ade80;\n --mk-warning: #fbbf24;\n --mk-danger: #f87171;\n --mk-limit: #a78bfa;\n}\n\n/*\n * ============================================================================\n * TIER 2 DERIVATION FORMULAS\n * ============================================================================\n * Every finer shade `doc.css` needs (a callout's tinted background, a\n * badge's tinted text) is a MIX of a Tier 1 hue against `--mk-bg`/`--mk-fg`,\n * never a literal of its own. Because the mix targets those two tokens\n * specifically, a dark host palette flips the derived shade's polarity\n * correctly with no extra work on the host's part — the whole point of\n * this refactor.\n *\n * Exactly three named percentages are used anywhere in this file. A future\n * component reuses one of these three; it does not invent a fourth.\n *\n * --mk-mix-variant-fill: color-mix(in srgb, <hue> 14%, var(--mk-bg))\n * A quiet tinted background — callout body fill.\n * --mk-mix-strong-fill: color-mix(in srgb, <hue> 18%, var(--mk-bg))\n * A slightly stronger tinted background — badge background.\n * --mk-mix-ink: color-mix(in srgb, <hue> 85%, var(--mk-fg))\n * A hue nudged toward body text — usable as ink (badge text, star\n * color) or as a border (callout border).\n *\n * `color-mix()` is unsupported in most email clients, and a custom property\n * whose value fails to parse does not fall back — the declaration goes\n * invalid-at-computed-value and effectively vanishes. `doc.css` is embedded\n * verbatim into `@markii/html`'s `exportHtmlDocument`, whose documented\n * targets include email and archive output, so every derived token below is\n * defined TWICE: first as a literal hex (today's existing light-mode\n * value), then, guarded by `@supports (color: color-mix(in srgb, red,\n * red))`, redefined via the real mix. A modern browser or either Electron\n * host gets live derivation that tracks a remapped Tier 1 palette; an old\n * email client silently keeps exactly today's light palette.\n */\n.doc {\n /* ---- literal light-mode fallback (used verbatim where color-mix is unsupported) ---- */\n --mk-info-fill: #eff6ff;\n --mk-info-strong-fill: #dbeafe;\n --mk-info-ink: #3b82f6;\n --mk-success-strong-fill: #dcfce7;\n --mk-success-ink: #15803d;\n --mk-warning-fill: #fffbeb;\n --mk-warning-strong-fill: #fef3c7;\n --mk-warning-ink: #d97706;\n --mk-danger-fill: #fef2f2;\n --mk-danger-strong-fill: #fee2e2;\n --mk-danger-ink: #dc2626;\n --mk-limit-ink: #7c3aed;\n /*\n * The keycap's inset depth line. Not a themed hue, but not theme-neutral\n * either: a low-alpha BLACK line is invisible on a dark surface, so the\n * literal below is only the no-`color-mix` fallback (where the palette is\n * the light one anyway). The derivation in the `@supports` block below\n * expresses it against `--mk-fg` instead, so it flips to a light line\n * when a host supplies a dark palette, which is the correct depth cue\n * there. Kept in this block, not the Tier 1 block above, so its literal\n * stays inside a block the no-raw-color-literal test allows.\n */\n --mk-shadow-sm: rgba(0, 0, 0, 0.05);\n}\n\n@supports (color: color-mix(in srgb, red, red)) {\n .doc {\n --mk-shadow-sm: color-mix(in srgb, var(--mk-fg) 8%, transparent);\n\n --mk-info-fill: color-mix(in srgb, var(--mk-info) 14%, var(--mk-bg));\n --mk-info-strong-fill: color-mix(in srgb, var(--mk-info) 18%, var(--mk-bg));\n --mk-info-ink: color-mix(in srgb, var(--mk-info) 85%, var(--mk-fg));\n\n --mk-success-strong-fill: color-mix(\n in srgb,\n var(--mk-success) 18%,\n var(--mk-bg)\n );\n --mk-success-ink: color-mix(in srgb, var(--mk-success) 85%, var(--mk-fg));\n\n --mk-warning-fill: color-mix(in srgb, var(--mk-warning) 14%, var(--mk-bg));\n --mk-warning-strong-fill: color-mix(\n in srgb,\n var(--mk-warning) 18%,\n var(--mk-bg)\n );\n --mk-warning-ink: color-mix(in srgb, var(--mk-warning) 85%, var(--mk-fg));\n\n --mk-danger-fill: color-mix(in srgb, var(--mk-danger) 14%, var(--mk-bg));\n --mk-danger-strong-fill: color-mix(\n in srgb,\n var(--mk-danger) 18%,\n var(--mk-bg)\n );\n --mk-danger-ink: color-mix(in srgb, var(--mk-danger) 85%, var(--mk-fg));\n\n --mk-limit-ink: color-mix(in srgb, var(--mk-limit) 85%, var(--mk-fg));\n }\n}\n\n.doc {\n color: var(--mk-fg);\n font-family:\n system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n line-height: 1.6;\n}\n\n.doc pre {\n overflow-x: auto;\n background: var(--mk-surface);\n padding: 0.75rem 1rem;\n border-radius: 6px;\n}\n\n.doc code {\n background: var(--mk-surface-strong);\n border-radius: 3px;\n padding: 0.1em 0.35em;\n font-size: 0.9em;\n}\n\n.doc pre code {\n background: none;\n padding: 0;\n}\n\n/* ---------- GFM table ---------- */\n\n/*\n * `display: block` on the table itself (rather than wrapping it in an\n * extra element the renderer doesn't otherwise inject) is what makes a wide\n * table scroll horizontally instead of overflowing the page or the doc\n * column — the table box becomes independently scrollable content, the\n * same trick used by GitHub's own Markdown rendering.\n */\n.doc table {\n display: block;\n overflow-x: auto;\n border-collapse: collapse;\n font-size: 0.95em;\n}\n\n.doc th,\n.doc td {\n border: 1px solid var(--mk-border);\n padding: 0.4rem 0.75rem;\n text-align: left;\n}\n\n.doc th {\n font-weight: 600;\n background: var(--mk-surface-strong);\n}\n\n.doc tr:nth-child(even) {\n background: var(--mk-surface);\n}\n\n/* ---------- GFM task list ---------- */\n\n/*\n * `li:has(> input[type=\"checkbox\"])` scopes bullet removal + checkbox\n * alignment to task-list items only — an ordinary `<ul>`/`<ol>` item keeps\n * its normal bullet/number, since GFM only adds a leading `<input>` to\n * items that used `- [ ]`/`- [x]` syntax.\n */\n.doc li:has(> input[type='checkbox']) {\n list-style: none;\n margin-inline-start: -1.5em;\n}\n\n.doc li > input[type='checkbox'] {\n margin-inline-end: 0.5em;\n vertical-align: middle;\n}\n\n/* ---------- callout ---------- */\n\n.mk-callout {\n border: 1px solid var(--mk-callout-border, var(--mk-border));\n border-left-width: 4px;\n border-radius: 6px;\n padding: 0.75rem 1rem;\n background: var(--mk-callout-bg, var(--mk-surface));\n}\n\n.mk-callout--info {\n --mk-callout-border: var(--mk-info-ink);\n --mk-callout-bg: var(--mk-info-fill);\n}\n\n.mk-callout--warning {\n --mk-callout-border: var(--mk-warning-ink);\n --mk-callout-bg: var(--mk-warning-fill);\n}\n\n.mk-callout--danger {\n --mk-callout-border: var(--mk-danger-ink);\n --mk-callout-bg: var(--mk-danger-fill);\n}\n\n.mk-callout__header {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-weight: 600;\n}\n\n.mk-callout__icon {\n line-height: 1;\n}\n\n/*\n * `display: flex; flex-direction: column` is load-bearing here, not just\n * `> * + *` margin: several block-holding components (`stat`, `badge`)\n * declare their OWN outer display as `inline-flex`/`inline-block` (correct\n * for sitting inline in a sentence), so without a flex/grid parent they\n * flow side-by-side on the same line instead of stacking — a\n * `margin-block-start` on a same-line inline-level sibling creates no\n * visible gap. Making the body a column flex container forces every\n * child's *used* display to blockify (CSS Flexbox: a flex item's outer\n * display is always block-level), so they stack regardless of the\n * component's own declared display, and the margin rule below then has\n * something to actually separate.\n */\n.mk-callout__body {\n display: flex;\n flex-direction: column;\n}\n\n.mk-callout__body > :first-child {\n margin-block-start: 0.5rem;\n}\n\n.mk-callout__body > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- kbd ---------- */\n\n.mk-kbd {\n display: inline-block;\n vertical-align: baseline;\n line-height: 1.4;\n height: 1.4em;\n padding: 0 0.4em;\n font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;\n font-size: 0.85em;\n border: 1px solid var(--mk-border);\n border-bottom-width: 2px;\n border-radius: 4px;\n background: var(--mk-surface);\n box-shadow: inset 0 -1px 0 var(--mk-shadow-sm);\n}\n\n/* ---------- rating ---------- */\n\n.mk-rating {\n display: inline-flex;\n gap: 0.15em;\n font-size: 1.1em;\n color: var(--mk-faint);\n}\n\n.mk-rating__star--filled {\n color: var(--mk-warning);\n}\n\n/* ---------- divider ---------- */\n\n/*\n * The `::before`/`::after` pseudo-elements are the flanking hairlines; the\n * label sits between them as ordinary flex children. The label carries its\n * own inline margin rather than a flex `gap` because an unlabeled divider\n * has no label element to create a gap around — with `gap` the rule would\n * split into two disconnected segments instead of staying one unbroken\n * line.\n */\n\n.mk-divider {\n display: flex;\n align-items: center;\n color: var(--mk-faint);\n}\n\n.mk-divider::before,\n.mk-divider::after {\n content: '';\n flex: 1 1 0;\n border-block-start: 1px solid var(--mk-border);\n}\n\n.mk-divider--dots::before,\n.mk-divider--dots::after {\n border-block-start-style: dotted;\n}\n\n.mk-divider--ornament::before,\n.mk-divider--ornament::after {\n border-block-start-color: transparent;\n}\n\n.mk-divider--label-left::before {\n flex: 0 0 1.5rem;\n}\n\n.mk-divider--label-right::after {\n flex: 0 0 1.5rem;\n}\n\n.mk-divider__label {\n margin-inline: 0.75em;\n font-size: 0.85em;\n color: var(--mk-muted);\n}\n\n.mk-divider__ornament {\n margin-inline: 0.35em;\n font-size: 0.9em;\n}\n\n/* ---------- value interpolation ---------- */\n\n.mk-value {\n display: inline;\n vertical-align: baseline;\n}\n\n.mk-value--stale {\n color: var(--mk-warning-ink);\n border-bottom: 1px dashed var(--mk-warning-ink);\n}\n\n.mk-value--missing {\n font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;\n font-size: 0.9em;\n color: var(--mk-faint);\n font-style: italic;\n}\n\n/*\n * Failure-kind modifiers (`@markii/runtime`'s `FailureKind`, docs/scripting.md) —\n * layered on top of `.mk-value--missing`, only ever added when the\n * resolution's root entry carried a `failureKind` on a genuine error (see\n * `ValueDirective`). Each just tints the dashed-underline treatment a\n * distinct hue so a reader can tell \"script bug\" apart from \"needs\n * permission\" / \"needs a manual run\" / \"hit a resource limit\" at a glance,\n * with the full message still available via the `title` tooltip.\n *\n * Failure kinds map onto the Tier 1 semantic hues: script-error -> danger,\n * capability-denied -> warning, tier-blocked -> info, limit -> limit.\n */\n.mk-value--script-error {\n border-bottom: 1px dashed var(--mk-danger-ink);\n}\n\n.mk-value--capability-denied {\n border-bottom: 1px dashed var(--mk-warning-ink);\n}\n\n.mk-value--tier-blocked {\n border-bottom: 1px dashed var(--mk-info-ink);\n}\n\n.mk-value--limit {\n border-bottom: 1px dashed var(--mk-limit-ink);\n}\n\n/* ---------- script marker ---------- */\n\n.mk-script {\n border: 1px solid var(--mk-border);\n border-radius: 6px;\n background: var(--mk-surface);\n}\n\n.mk-script__summary {\n cursor: pointer;\n padding: 0.5rem 0.75rem;\n font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;\n font-size: 0.85em;\n color: var(--mk-muted);\n}\n\n.mk-script[open] > .mk-script__summary {\n border-bottom: 1px solid var(--mk-border);\n}\n\n.mk-script__code {\n margin: 0.75rem;\n margin-block-start: 0;\n}\n\n.mk-script__empty {\n margin: 0.75rem;\n margin-block-start: 0;\n font-size: 0.85em;\n font-style: italic;\n color: var(--mk-faint);\n}\n\n/* ---------- details ---------- */\n\n.mk-details {\n border: 1px solid var(--mk-border);\n border-radius: 6px;\n padding: 0.75rem 1rem;\n background: var(--mk-surface);\n}\n\n.mk-details__summary {\n cursor: pointer;\n font-weight: 600;\n}\n\n.mk-details[open] > .mk-details__summary {\n margin-block-end: 0.5rem;\n}\n\n/* Blockifies inline-flex/inline-block children (`stat`, `badge`, ...) so they stack — see `.mk-callout__body`'s comment above for why this is load-bearing. */\n.mk-details__body {\n display: flex;\n flex-direction: column;\n}\n\n.mk-details__body > :first-child {\n margin-block-start: 0.5rem;\n}\n\n.mk-details__body > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- card ---------- */\n\n.mk-card {\n border: 1px solid var(--mk-border);\n border-radius: 8px;\n padding: 1rem;\n background: var(--mk-raised);\n}\n\n.mk-card__title {\n font-weight: 600;\n margin-block-end: 0.5rem;\n}\n\n/* Blockifies inline-flex/inline-block children (`stat`, `badge`, ...) so they stack — see `.mk-callout__body`'s comment above for why this is load-bearing. */\n.mk-card__body {\n display: flex;\n flex-direction: column;\n}\n\n.mk-card__body > :first-child {\n margin-block-start: 0;\n}\n\n.mk-card__body > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- badge ---------- */\n\n.mk-badge {\n display: inline-block;\n vertical-align: baseline;\n line-height: 1.4;\n height: 1.4em;\n padding: 0 0.6em;\n font-size: 0.8em;\n font-weight: 600;\n border-radius: 999px;\n color: var(--mk-badge-fg, var(--mk-fg));\n background: var(--mk-badge-bg, var(--mk-surface-strong));\n}\n\n.mk-badge--info {\n --mk-badge-fg: var(--mk-info-ink);\n --mk-badge-bg: var(--mk-info-strong-fill);\n}\n\n.mk-badge--success {\n --mk-badge-fg: var(--mk-success-ink);\n --mk-badge-bg: var(--mk-success-strong-fill);\n}\n\n.mk-badge--warning {\n --mk-badge-fg: var(--mk-warning-ink);\n --mk-badge-bg: var(--mk-warning-strong-fill);\n}\n\n.mk-badge--danger {\n --mk-badge-fg: var(--mk-danger-ink);\n --mk-badge-bg: var(--mk-danger-strong-fill);\n}\n\n/* ---------- figure ---------- */\n\n.mk-figure {\n margin: 0;\n}\n\n.mk-figure__img {\n display: block;\n max-width: 100%;\n height: auto;\n border-radius: 6px;\n}\n\n.mk-figure__caption {\n margin-block-start: 0.5rem;\n font-size: 0.9em;\n color: var(--mk-muted);\n}\n\n.mk-figure__caption > :first-child {\n margin-block-start: 0;\n}\n\n.mk-figure__caption > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- tabs ---------- */\n\n.mk-tabs__list {\n display: flex;\n gap: 0.25rem;\n border-bottom: 1px solid var(--mk-border);\n}\n\n.mk-tabs__button {\n cursor: pointer;\n border: none;\n background: none;\n padding: 0.5rem 0.9rem;\n font: inherit;\n font-weight: 600;\n color: var(--mk-muted);\n border-bottom: 2px solid transparent;\n margin-block-end: -1px;\n}\n\n.mk-tabs__button--active {\n color: var(--mk-accent);\n border-bottom-color: var(--mk-accent);\n}\n\n/* Blockifies inline-flex/inline-block children (`stat`, `badge`, ...) so they stack — see `.mk-callout__body`'s comment above for why this is load-bearing. */\n.mk-tab {\n display: flex;\n flex-direction: column;\n padding-block-start: 0.75rem;\n}\n\n.mk-tab > :first-child {\n margin-block-start: 0;\n}\n\n.mk-tab > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- stat ---------- */\n\n.mk-stat {\n display: inline-flex;\n flex-direction: column;\n gap: 0.15rem;\n}\n\n.mk-stat__value {\n font-size: 1.8em;\n font-weight: 700;\n line-height: 1.2;\n}\n\n.mk-stat__label {\n font-size: 0.85em;\n color: var(--mk-muted);\n}\n\n.mk-stat__delta {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--mk-muted);\n}\n\n.mk-stat__delta--up {\n color: var(--mk-success-ink);\n}\n\n.mk-stat__delta--down {\n color: var(--mk-danger-ink);\n}\n\n.mk-stat__delta--flat {\n color: var(--mk-muted);\n}\n\n/* ---------- progress ---------- */\n\n.mk-progress {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n}\n\n.mk-progress__label {\n font-size: 0.85em;\n color: var(--mk-muted);\n flex: 0 0 auto;\n}\n\n.mk-progress__track {\n flex: 1 1 auto;\n height: 0.6rem;\n border-radius: 999px;\n background: var(--mk-border);\n overflow: hidden;\n}\n\n.mk-progress__bar {\n height: 100%;\n background: var(--mk-accent);\n border-radius: inherit;\n}\n\n.mk-progress__percent {\n flex: 0 0 auto;\n font-size: 0.85em;\n font-variant-numeric: tabular-nums;\n color: var(--mk-muted);\n}\n\n/* ---------- chart ---------- */\n\n.mk-chart {\n display: block;\n max-width: 100%;\n}\n\n.mk-chart__line {\n fill: none;\n stroke: var(--mk-accent);\n stroke-width: 2;\n stroke-linejoin: round;\n stroke-linecap: round;\n}\n\n.mk-chart__bar {\n fill: var(--mk-accent);\n}\n\n.mk-chart--empty {\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.8em;\n font-style: italic;\n color: var(--mk-faint);\n border: 1px dashed var(--mk-border);\n border-radius: 6px;\n}\n\n/* ---------- table (data-bound) ---------- */\n\n/*\n * `.mk-table` is the ROOT wrapper `Table` renders (docs/format.md's\n * `::table`), holding an optional `.mk-table__caption` and the real\n * `<table class=\"mk-table__table\">`. The `<table>`/`<th>`/`<td>` elements\n * inside it are deliberately left to `.doc table`/`.doc th`/`.doc td`\n * above — the same rules a bare GFM table already gets — so `mk-table`\n * never forks or duplicates that styling; only the wrapper and its two\n * non-`<table>` children need rules of their own.\n */\n.mk-table {\n display: block;\n}\n\n.mk-table__caption {\n font-size: 0.85em;\n color: var(--mk-muted);\n margin-block-end: 0.4rem;\n}\n\n.mk-table--empty .mk-table__empty {\n font-size: 0.85em;\n font-style: italic;\n color: var(--mk-faint);\n border: 1px dashed var(--mk-border);\n border-radius: 6px;\n padding: 0.5rem 0.75rem;\n}\n\n/* ---------- data-binding state (docs/scripting.md) ---------- */\n\n/*\n * The block twins of the `.mk-value--*` markers above: a data-bound\n * component (`stat`, `progress`, `chart`) whose `data=` binding is stale or\n * failed keeps its ordinary QUIET body — `—`, a `0%` bar, `no data` — and\n * says so only through these hooks plus its `title` tooltip. No rule here\n * may add body text (`content:` is deliberately absent) or an outer margin;\n * the failure taxonomy's wording lives in one place only, `components/\n * failure-presentation.ts`.\n *\n * The hues match `.mk-value--*` exactly, so the same failing name reads the\n * same whether it surfaced inline via `:value[...]` or as a component.\n */\n.mk-stat--stale,\n.mk-progress--stale,\n.mk-chart--stale,\n.mk-table--stale {\n opacity: 0.8;\n}\n\n.mk-stat--script-error,\n.mk-progress--script-error,\n.mk-chart--script-error,\n.mk-table--script-error {\n border-bottom: 2px solid var(--mk-danger-ink);\n}\n\n.mk-stat--capability-denied,\n.mk-progress--capability-denied,\n.mk-chart--capability-denied,\n.mk-table--capability-denied {\n border-bottom: 2px solid var(--mk-warning-ink);\n}\n\n.mk-stat--tier-blocked,\n.mk-progress--tier-blocked,\n.mk-chart--tier-blocked,\n.mk-table--tier-blocked {\n border-bottom: 2px solid var(--mk-info-ink);\n}\n\n.mk-stat--limit,\n.mk-progress--limit,\n.mk-chart--limit,\n.mk-table--limit {\n border-bottom: 2px solid var(--mk-limit-ink);\n}\n\n/* ---------- layout presets (docs/format.md) ---------- */\n\n/*\n * `render.tsx` only wraps a directive in a `mk-width-*`/`mk-align-*` `<div>`\n * when at least one of these classes actually applies, so the wrapper below\n * IS the element sitting directly in `.doc`'s rhythm flow — never\n * `margin-block` here, that's `.doc > * + *`'s job alone; setting it on the\n * wrapper too would double up spacing. `max-width: min(<size>, 100%)` keeps\n * every preset from ever overflowing the document column, even on a narrow\n * viewport where the size below is wider than the column itself.\n */\n.mk-width-narrow {\n max-width: min(var(--mk-width-narrow), 100%);\n}\n\n.mk-width-wide {\n max-width: min(var(--mk-width-wide), 100%);\n}\n\n/* \"full\" is the full available column width — not a viewport-bleed hack with negative margins. */\n.mk-width-full {\n max-width: var(--mk-width-full);\n}\n\n/*\n * \"fit\" is the one preset that sets `width` rather than only capping it: it\n * shrinks the block to its own content instead of filling the column, and\n * `max-width` keeps that from overflowing when the content is wider than\n * the column. Because the box is now narrower than its container, the\n * `mk-align-*` auto margins below finally have room to work, which is what\n * makes `{width=fit align=right}` hug the content AND sit right.\n */\n.mk-width-fit {\n width: var(--mk-width-fit);\n max-width: 100%;\n}\n\n.mk-align-left {\n margin-inline-end: auto;\n}\n\n.mk-align-center {\n margin-inline: auto;\n}\n\n.mk-align-right {\n margin-inline-start: auto;\n}\n\n/*\n * The `:::center`/`:::left`/`:::right`/`:::wide`/`:::narrow`/`:::full`/`:::fit`\n * layout wrappers (docs/format.md, `layout-wrapper.tsx`) reuse the `mk-width-*`/\n * `mk-align-*` classes above and add `mk-layout` on top for the rules below,\n * which only make sense on a container that has its OWN plain-markdown\n * children (a table, an image, a paragraph) rather than on the bare\n * attribute-interception wrapper `render.tsx` emits for `width=`/`align=`.\n *\n * No outer margin on `.mk-layout` itself, same rule as every component\n * (Architecture rule 4) — `.doc > * + *` spaces the wrapper against its\n * siblings. This rule instead restores RHYTHM *inside* the wrapper's own\n * scope, mirroring `.mk-card__body > * + *`: without it, the wrapper's\n * children would have no spacing between them at all, since `.doc > * + *`\n * only ever sees the wrapper `<div>` as a whole, never reaches inside it.\n */\n.mk-layout > * + * {\n margin-block-start: 1rem;\n}\n\n/*\n * An alignment wrapper additionally sets text alignment for everything in\n * scope, not just the shrink-to-fit block alignment below. `left` carries\n * its own rule for a reason: it is the one wrapper written specifically to\n * opt a scope back OUT of an alignment it inherited (a cell of a\n * `:::row{text=center}`), and only a DECLARED value beats an inherited one.\n * Without this rule `:::left` would silently keep the centered text it was\n * written to undo.\n */\n.mk-layout.mk-align-left {\n text-align: left;\n}\n\n.mk-layout.mk-align-center {\n text-align: center;\n}\n\n.mk-layout.mk-align-right {\n text-align: right;\n}\n\n/*\n * `.mk-align-center`/`.mk-align-right` above (shared with the `align=`\n * attribute wrapper) only center/right-align the wrapper `<div>` ITSELF\n * within ITS container — they say nothing about the wrapper's own children.\n * These two rules do that: they shrink-to-fit and align every direct child\n * of the wrapper's scope, which is what actually centers/right-aligns a\n * narrower-than-column table or image sitting inside `:::center`/`:::right`.\n */\n.mk-layout.mk-align-center > * {\n margin-inline: auto;\n}\n\n.mk-layout.mk-align-right > * {\n margin-inline-start: auto;\n margin-inline-end: 0;\n}\n\n/*\n * `.doc table` (above) sets `display: block` for horizontal-scroll\n * overflow, which also makes the table fill its column — defeating\n * shrink-to-fit alignment before it can even apply. These two rules size a\n * table down to its content instead, so the `margin-inline` rules above\n * have a narrower box to actually move. `.doc th`/`.doc td` set\n * `text-align: left` directly on the cells, so this scope's `text-align`\n * never flips table cell text — only the table's own position in the\n * scope, and any non-table text alongside it.\n */\n.mk-layout.mk-align-center > table,\n.mk-layout.mk-align-right > table {\n width: fit-content;\n max-width: 100%;\n}\n\n/* ---------- text alignment inside a component (docs/format.md) ---------- */\n\n/*\n * The `text` attribute of `row`, `cell`, `card`, and `callout`. Deliberately\n * separate from the `mk-align-*` classes above: those place a block's BOX\n * within the column and never touch its contents, while these align the\n * content inside one component and never move its box. Two different jobs,\n * two different class names, so neither can be mistaken for the other.\n *\n * One rule per value, defined once and honored by all four components, which\n * is what lets `:::row{text=center}` reach its cells through ordinary CSS\n * inheritance: `.mk-cell` declares no `text-align` of its own, so the row's\n * value flows in, and a cell that declares its own (or an alignment wrapper\n * written inside it) wins simply by being declared.\n */\n.mk-text-left {\n text-align: left;\n}\n\n.mk-text-center {\n text-align: center;\n}\n\n.mk-text-right {\n text-align: right;\n}\n\n/* ---------- row ---------- */\n\n.mk-row {\n display: grid;\n gap: 1rem;\n grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));\n}\n\n/*\n * `minmax(0, 1fr)` — a bare `1fr` track can still grow past an equal share\n * to fit a wide intrinsic-content cell (e.g. a `chart` SVG, a long code\n * span); pinning the minimum to `0` is what keeps such a cell, and\n * therefore the whole row, from blowing out past its column.\n */\n.mk-row--cols-2 {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n.mk-row--cols-3 {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n}\n\n.mk-row--cols-4 {\n grid-template-columns: repeat(4, minmax(0, 1fr));\n}\n\n/*\n * No separate blockification rule is needed here, unlike `.mk-card__body`'s:\n * a grid container (which `.mk-row` is) already promotes every direct\n * child's outer display to block-level as a grid item, same as a flex\n * container does — an inline-flex/inline-block component (`stat`, `badge`,\n * ...) used directly as a row cell already stacks/sizes as a proper grid\n * cell with no extra rule required.\n */\n\n@media (max-width: 40rem) {\n .mk-row,\n .mk-row--cols-2,\n .mk-row--cols-3,\n .mk-row--cols-4 {\n grid-template-columns: 1fr;\n }\n}\n\n/* ---------- cell ---------- */\n\n/*\n * `:::cell` (`cell.tsx`) is a TRANSPARENT grouping container: it has no\n * border, background, padding, or outer margin of its own — its whole job is\n * making several blocks count as ONE `.mk-row` grid cell, so anything visual\n * here would betray that. `.mk-cell` therefore has no rule at all; only the\n * rhythm-restoring rule below exists, mirroring `.mk-layout > * + *`:\n * `.doc > * + *` sees the cell as a single box and never reaches inside it,\n * so without this its children would sit flush against each other.\n */\n.mk-cell > * + * {\n margin-block-start: 1rem;\n}\n\n/* ---------- empty inline-component marker ---------- */\n\n/*\n * Wraps an `inline: true` component that received no content\n * (`::badge{label=\"x\"}` instead of `:badge[x]`) — see `render.tsx`'s\n * `isRegisteredInline`/`isEmptyContent` and `render.ts`'s HTML-engine\n * mirror. The component underneath renders unchanged; this is a quiet\n * perceptual hook only (a faint dashed underline, matching the treatment\n * `.mk-value--stale` already gives a quiet-but-present state), with the\n * reason carried in the `title` tooltip rather than in the page.\n */\n.mk-inline-empty {\n border-bottom: 1px dashed var(--mk-faint);\n}\n\n/* ---------- unknown directive fallback ---------- */\n\n.mk-unknown {\n border: 1px dashed var(--mk-faint);\n border-radius: 6px;\n color: var(--mk-muted);\n}\n\n.mk-unknown--block {\n padding: 0.75rem 1rem;\n}\n\n.mk-unknown--inline {\n display: inline-flex;\n align-items: baseline;\n gap: 0.4em;\n padding: 0 0.4em;\n vertical-align: baseline;\n}\n\n.mk-unknown__label {\n font-size: 0.85em;\n font-style: italic;\n margin: 0;\n}\n\n.mk-unknown__content > :first-child {\n margin-block-start: 0.5rem;\n}\n\n.mk-unknown__content > * + * {\n margin-block-start: 0.5rem;\n}\n";
5
+ export const DOC_CSS = "/*\n * Document rhythm: components own their insides only, never outer margins.\n * This single rule spaces every block-level child of `.doc` identically —\n * paragraphs, headings, and components alike — so new components always\n * sit correctly in the flow with zero per-component tuning.\n */\n:where(.doc, [data-mk-root]) > * + * {\n margin-block-start: 1rem;\n}\n\n/*\n * ============================================================================\n * TIER 1 TOKENS — the theming contract\n * ============================================================================\n * Every host that wants a themed (in particular, a dark) `.doc` remaps ONLY\n * these custom properties, deliberately scoped to `.doc` and not `:root`:\n * `doc.css` is a library stylesheet embedded into other people's pages\n * (Obsidian injects it globally), so it must stay polite and never claim\n * `:root`. A host theme layer loaded after this one, at the same\n * specificity, simply redeclares the ones below on `.doc` (or a more\n * specific selector) to win the cascade — see `apps/vscode/src/webview/\n * theme.css` and `apps/obsidian/src/obsidian-theme.css`.\n *\n * Nine neutrals + five semantic hues. This list is deliberately small: a\n * host maps ~14 tokens instead of ~60 individual selectors, and a\n * derivation layer below (Tier 2) builds every finer shade FROM these, so\n * remapping these 14 makes every callout/badge/chart/etc. variant\n * theme-correct for free.\n */\n.doc {\n /* ---------- neutrals ---------- */\n --mk-bg: #fff; /* page ground */\n --mk-raised: #fff; /* raised surface for cards — distinct from ground; dark themes need real separation here where light themes get it from the border alone */\n --mk-fg: #1a1a1a; /* body text */\n --mk-surface: #f4f4f5; /* subtle fill one step off the ground: pre, kbd, script marker, details, default callout, zebra rows */\n --mk-surface-strong: #f0f0f2; /* second step off the ground: inline code, th, progress track, default badge background */\n --mk-border: #e4e4e7; /* hairlines */\n --mk-muted: #52525b; /* secondary text: captions, labels, script summary */\n --mk-faint: #94a3b8; /* tertiary/empty: missing values, empty-chart text, unfilled stars */\n --mk-accent: #3b82f6; /* the single interactive/brand color: active tab, progress bar, chart stroke/fill */\n --mk-on-accent: #fff; /* ink that sits ON a solid --mk-accent fill; a host must supply this because it cannot be derived: it depends on the accent's own lightness, not on the page palette */\n\n /* ---------- semantic hues ---------- */\n --mk-info: #3b82f6;\n --mk-success: #15803d;\n --mk-warning: #d97706;\n --mk-danger: #dc2626;\n --mk-limit: #7c3aed; /* the purple, used for the \"hit a resource limit\" failure kind */\n\n /* ---------- width presets (docs/format.md) ---------- */\n /*\n * The sizing half of the `width=`/`:::wide` etc. layout presets, as\n * hooks a host theme layer can override (AGENTS.md's \"New Tier 1 token\"\n * rule) — e.g. a host with a narrower reading column can shrink\n * `--mk-width-wide` without touching any selector below. `fit` and\n * `full` are exposed too even though their current values are a keyword\n * and `100%` respectively, so all four sized presets are equally\n * themeable; `normal` (the explicit default) produces no class and no\n * box to size, so it has no token — there is nothing for a host to hook.\n */\n --mk-width-fit: fit-content;\n --mk-width-narrow: 30rem;\n --mk-width-wide: 64rem;\n --mk-width-full: 100%;\n}\n\n/*\n * ============================================================================\n * DARK MODE (exported documents only) + the data-mk-theme override\n * ============================================================================\n * `@markii/html`'s `exportHtmlDocument` produces a standalone page with no\n * host page around it, so this is the one place `doc.css` picks a dark\n * palette for itself, guarded by `prefers-color-scheme: dark` rather than a\n * class, since a standalone file has nothing to put a class on.\n *\n * This does not touch how a HOST theme layer works. `apps/vscode/src/\n * webview/theme.css` and `apps/obsidian/src/obsidian-theme.css` are loaded\n * AFTER `doc.css` and redeclare these same tokens on a plain `.doc`\n * selector, unconditionally. At equal specificity the later, unconditional\n * rule always wins the cascade over this earlier, media-guarded one,\n * regardless of the reader's OS preference, so a themed host's own\n * light/dark handling is unaffected either way. Only a page with no such\n * layer after it, an exported document, ever resolves these.\n *\n * A viewer with no preference at all matches neither `light` nor `dark`\n * media features, so the Tier 1 defaults above stay exactly as they were.\n *\n * `data-mk-theme` lets a page or a host that manages its OWN theme opt out\n * of, or force, this automatic palette: `data-mk-theme=\"light\"` keeps the\n * light Tier 1 defaults no matter what the OS prefers, and\n * `data-mk-theme=\"dark\"` applies the dark palette unconditionally, outside\n * any media query. Both are attribute selectors on `.doc`\n * (specificity 0,2,0), deliberately higher than the plain `.doc` this\n * media block uses (0,1,0) and higher than the host theme layers above, so\n * an explicit attribute always wins over both the OS preference and a\n * host's own remap. The media block above is left at plain `.doc` on\n * purpose, NOT `.doc:not([data-mk-theme=\"light\"])`: raising ITS specificity\n * to (0,2,0) would make it beat the host theme layers too (which redeclare\n * on plain `.doc` and rely on winning by being later in the cascade at\n * equal specificity), breaking both hosts on a dark-preference machine. A\n * host that never sets `data-mk-theme` sees no change at all; the two\n * attribute values are an opt-in a page reaches for deliberately.\n *\n * The dark values below are declared twice on purpose (media-guarded and\n * forced), because `@media` cannot wrap only part of a selector list and\n * the forced path must apply even when the OS prefers light. `doc-css-\n * tokens.test.ts` asserts the two blocks carry identical values so they\n * cannot drift apart under a one-sided edit.\n */\n@media (prefers-color-scheme: dark) {\n .doc {\n --mk-bg: #14161c;\n --mk-raised: #1c1f28;\n --mk-fg: #e5e7eb;\n --mk-surface: #1e212b;\n --mk-surface-strong: #262a36;\n --mk-border: #333846;\n --mk-muted: #a1a8ba;\n --mk-faint: #6b7280;\n --mk-accent: #60a5fa;\n --mk-on-accent: #0b1220;\n\n --mk-info: #60a5fa;\n --mk-success: #4ade80;\n --mk-warning: #fbbf24;\n --mk-danger: #f87171;\n --mk-limit: #a78bfa;\n }\n}\n\n.doc[data-mk-theme='light'] {\n --mk-bg: #fff;\n --mk-raised: #fff;\n --mk-fg: #1a1a1a;\n --mk-surface: #f4f4f5;\n --mk-surface-strong: #f0f0f2;\n --mk-border: #e4e4e7;\n --mk-muted: #52525b;\n --mk-faint: #94a3b8;\n --mk-accent: #3b82f6;\n --mk-on-accent: #fff;\n\n --mk-info: #3b82f6;\n --mk-success: #15803d;\n --mk-warning: #d97706;\n --mk-danger: #dc2626;\n --mk-limit: #7c3aed;\n}\n\n.doc[data-mk-theme='dark'] {\n --mk-bg: #14161c;\n --mk-raised: #1c1f28;\n --mk-fg: #e5e7eb;\n --mk-surface: #1e212b;\n --mk-surface-strong: #262a36;\n --mk-border: #333846;\n --mk-muted: #a1a8ba;\n --mk-faint: #6b7280;\n --mk-accent: #60a5fa;\n --mk-on-accent: #0b1220;\n\n --mk-info: #60a5fa;\n --mk-success: #4ade80;\n --mk-warning: #fbbf24;\n --mk-danger: #f87171;\n --mk-limit: #a78bfa;\n}\n\n/*\n * ============================================================================\n * TIER 2 DERIVATION FORMULAS\n * ============================================================================\n * Every finer shade `doc.css` needs (a callout's tinted background, a\n * badge's tinted text) is a MIX of a Tier 1 hue against `--mk-bg`/`--mk-fg`,\n * never a literal of its own. Because the mix targets those two tokens\n * specifically, a dark host palette flips the derived shade's polarity\n * correctly with no extra work on the host's part — the whole point of\n * this refactor.\n *\n * Exactly three named percentages are used anywhere in this file. A future\n * component reuses one of these three; it does not invent a fourth.\n *\n * --mk-mix-variant-fill: color-mix(in srgb, <hue> 14%, var(--mk-bg))\n * A quiet tinted background — callout body fill.\n * --mk-mix-strong-fill: color-mix(in srgb, <hue> 18%, var(--mk-bg))\n * A slightly stronger tinted background — badge background.\n * --mk-mix-ink: color-mix(in srgb, <hue> 85%, var(--mk-fg))\n * A hue nudged toward body text — usable as ink (badge text, star\n * color) or as a border (callout border).\n *\n * `color-mix()` is unsupported in most email clients, and a custom property\n * whose value fails to parse does not fall back — the declaration goes\n * invalid-at-computed-value and effectively vanishes. `doc.css` is embedded\n * verbatim into `@markii/html`'s `exportHtmlDocument`, whose documented\n * targets include email and archive output, so every derived token below is\n * defined TWICE: first as a literal hex (today's existing light-mode\n * value), then, guarded by `@supports (color: color-mix(in srgb, red,\n * red))`, redefined via the real mix. A modern browser or either Electron\n * host gets live derivation that tracks a remapped Tier 1 palette; an old\n * email client silently keeps exactly today's light palette.\n *\n * Scoped to `:where(.doc, [data-mk-root])`, not `.doc` alone: a host that\n * renders into its own container class (not `.doc`) sets its Tier 1\n * overrides on that same element, and can opt this derivation layer (and\n * every other `.doc`-scoped rule below that a document needs — rhythm,\n * base typography, table/code/list styling) onto it by adding a bare\n * `data-mk-root` attribute, with no class rename and no forked stylesheet.\n * `:where()` keeps the added selector at zero specificity, so a host theme\n * layer that redeclares these on plain `.doc` still wins exactly as\n * before. Never hoisted to `:root`: a custom property resolves where it is\n * declared, so a `:root` derivation would freeze to whichever palette is\n * in scope there and could not re-derive for a differently themed\n * descendant.\n */\n:where(.doc, [data-mk-root]) {\n /* ---- literal light-mode fallback (used verbatim where color-mix is unsupported) ---- */\n --mk-info-fill: #eff6ff;\n --mk-info-strong-fill: #dbeafe;\n --mk-info-ink: #3b82f6;\n --mk-success-strong-fill: #dcfce7;\n --mk-success-ink: #15803d;\n --mk-warning-fill: #fffbeb;\n --mk-warning-strong-fill: #fef3c7;\n --mk-warning-ink: #d97706;\n --mk-danger-fill: #fef2f2;\n --mk-danger-strong-fill: #fee2e2;\n --mk-danger-ink: #dc2626;\n --mk-limit-ink: #7c3aed;\n /*\n * The keycap's inset depth line. Not a themed hue, but not theme-neutral\n * either: a low-alpha BLACK line is invisible on a dark surface, so the\n * literal below is only the no-`color-mix` fallback (where the palette is\n * the light one anyway). The derivation in the `@supports` block below\n * expresses it against `--mk-fg` instead, so it flips to a light line\n * when a host supplies a dark palette, which is the correct depth cue\n * there. Kept in this block, not the Tier 1 block above, so its literal\n * stays inside a block the no-raw-color-literal test allows.\n */\n --mk-shadow-sm: rgba(0, 0, 0, 0.05);\n}\n\n@supports (color: color-mix(in srgb, red, red)) {\n :where(.doc, [data-mk-root]) {\n --mk-shadow-sm: color-mix(in srgb, var(--mk-fg) 8%, transparent);\n\n --mk-info-fill: color-mix(in srgb, var(--mk-info) 14%, var(--mk-bg));\n --mk-info-strong-fill: color-mix(in srgb, var(--mk-info) 18%, var(--mk-bg));\n --mk-info-ink: color-mix(in srgb, var(--mk-info) 85%, var(--mk-fg));\n\n --mk-success-strong-fill: color-mix(\n in srgb,\n var(--mk-success) 18%,\n var(--mk-bg)\n );\n --mk-success-ink: color-mix(in srgb, var(--mk-success) 85%, var(--mk-fg));\n\n --mk-warning-fill: color-mix(in srgb, var(--mk-warning) 14%, var(--mk-bg));\n --mk-warning-strong-fill: color-mix(\n in srgb,\n var(--mk-warning) 18%,\n var(--mk-bg)\n );\n --mk-warning-ink: color-mix(in srgb, var(--mk-warning) 85%, var(--mk-fg));\n\n --mk-danger-fill: color-mix(in srgb, var(--mk-danger) 14%, var(--mk-bg));\n --mk-danger-strong-fill: color-mix(\n in srgb,\n var(--mk-danger) 18%,\n var(--mk-bg)\n );\n --mk-danger-ink: color-mix(in srgb, var(--mk-danger) 85%, var(--mk-fg));\n\n --mk-limit-ink: color-mix(in srgb, var(--mk-limit) 85%, var(--mk-fg));\n }\n}\n\n:where(.doc, [data-mk-root]) {\n color: var(--mk-fg);\n font-family:\n system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n line-height: 1.6;\n}\n\n:where(.doc, [data-mk-root]) pre {\n overflow-x: auto;\n background: var(--mk-surface);\n padding: 0.75rem 1rem;\n border-radius: 6px;\n}\n\n:where(.doc, [data-mk-root]) code {\n background: var(--mk-surface-strong);\n border-radius: 3px;\n padding: 0.1em 0.35em;\n font-size: 0.9em;\n}\n\n:where(.doc, [data-mk-root]) pre code {\n background: none;\n padding: 0;\n}\n\n/* ---------- GFM table ---------- */\n\n/*\n * `display: block` on the table itself (rather than wrapping it in an\n * extra element the renderer doesn't otherwise inject) is what makes a wide\n * table scroll horizontally instead of overflowing the page or the doc\n * column — the table box becomes independently scrollable content, the\n * same trick used by GitHub's own Markdown rendering.\n */\n:where(.doc, [data-mk-root]) table {\n display: block;\n overflow-x: auto;\n border-collapse: collapse;\n font-size: 0.95em;\n}\n\n:where(.doc, [data-mk-root]) th,\n:where(.doc, [data-mk-root]) td {\n border: 1px solid var(--mk-border);\n padding: 0.4rem 0.75rem;\n text-align: left;\n}\n\n:where(.doc, [data-mk-root]) th {\n font-weight: 600;\n background: var(--mk-surface-strong);\n}\n\n:where(.doc, [data-mk-root]) tr:nth-child(even) {\n background: var(--mk-surface);\n}\n\n/* ---------- GFM task list ---------- */\n\n/*\n * `li:has(> input[type=\"checkbox\"])` scopes bullet removal + checkbox\n * alignment to task-list items only — an ordinary `<ul>`/`<ol>` item keeps\n * its normal bullet/number, since GFM only adds a leading `<input>` to\n * items that used `- [ ]`/`- [x]` syntax.\n */\n:where(.doc, [data-mk-root]) li:has(> input[type='checkbox']) {\n list-style: none;\n margin-inline-start: -1.5em;\n}\n\n:where(.doc, [data-mk-root]) li > input[type='checkbox'] {\n margin-inline-end: 0.5em;\n vertical-align: middle;\n}\n\n/* ---------- callout ---------- */\n\n.mk-callout {\n border: 1px solid var(--mk-callout-border, var(--mk-border));\n border-left-width: 4px;\n border-radius: 6px;\n padding: 0.75rem 1rem;\n background: var(--mk-callout-bg, var(--mk-surface));\n}\n\n.mk-callout--info {\n --mk-callout-border: var(--mk-info-ink);\n --mk-callout-bg: var(--mk-info-fill);\n}\n\n.mk-callout--warning {\n --mk-callout-border: var(--mk-warning-ink);\n --mk-callout-bg: var(--mk-warning-fill);\n}\n\n.mk-callout--danger {\n --mk-callout-border: var(--mk-danger-ink);\n --mk-callout-bg: var(--mk-danger-fill);\n}\n\n.mk-callout__header {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n font-weight: 600;\n}\n\n.mk-callout__icon {\n line-height: 1;\n}\n\n/*\n * `display: flex; flex-direction: column` is load-bearing here, not just\n * `> * + *` margin: several block-holding components (`stat`, `badge`)\n * declare their OWN outer display as `inline-flex`/`inline-block` (correct\n * for sitting inline in a sentence), so without a flex/grid parent they\n * flow side-by-side on the same line instead of stacking — a\n * `margin-block-start` on a same-line inline-level sibling creates no\n * visible gap. Making the body a column flex container forces every\n * child's *used* display to blockify (CSS Flexbox: a flex item's outer\n * display is always block-level), so they stack regardless of the\n * component's own declared display, and the margin rule below then has\n * something to actually separate.\n */\n.mk-callout__body {\n display: flex;\n flex-direction: column;\n}\n\n.mk-callout__body > :first-child {\n margin-block-start: 0.5rem;\n}\n\n.mk-callout__body > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- kbd ---------- */\n\n.mk-kbd {\n display: inline-block;\n vertical-align: baseline;\n line-height: 1.4;\n height: 1.4em;\n padding: 0 0.4em;\n font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;\n font-size: 0.85em;\n border: 1px solid var(--mk-border);\n border-bottom-width: 2px;\n border-radius: 4px;\n background: var(--mk-surface);\n box-shadow: inset 0 -1px 0 var(--mk-shadow-sm);\n}\n\n/* ---------- rating ---------- */\n\n.mk-rating {\n display: inline-flex;\n gap: 0.15em;\n font-size: 1.1em;\n color: var(--mk-faint);\n}\n\n.mk-rating__star--filled {\n color: var(--mk-warning);\n}\n\n/* ---------- divider ---------- */\n\n/*\n * The `::before`/`::after` pseudo-elements are the flanking hairlines; the\n * label sits between them as ordinary flex children. The label carries its\n * own inline margin rather than a flex `gap` because an unlabeled divider\n * has no label element to create a gap around — with `gap` the rule would\n * split into two disconnected segments instead of staying one unbroken\n * line.\n */\n\n.mk-divider {\n display: flex;\n align-items: center;\n color: var(--mk-faint);\n}\n\n.mk-divider::before,\n.mk-divider::after {\n content: '';\n flex: 1 1 0;\n border-block-start: 1px solid var(--mk-border);\n}\n\n.mk-divider--dots::before,\n.mk-divider--dots::after {\n border-block-start-style: dotted;\n}\n\n.mk-divider--ornament::before,\n.mk-divider--ornament::after {\n border-block-start-color: transparent;\n}\n\n.mk-divider--label-left::before {\n flex: 0 0 1.5rem;\n}\n\n.mk-divider--label-right::after {\n flex: 0 0 1.5rem;\n}\n\n.mk-divider__label {\n margin-inline: 0.75em;\n font-size: 0.85em;\n color: var(--mk-muted);\n}\n\n.mk-divider__ornament {\n margin-inline: 0.35em;\n font-size: 0.9em;\n}\n\n/* ---------- value interpolation ---------- */\n\n.mk-value {\n display: inline;\n vertical-align: baseline;\n}\n\n.mk-value--stale {\n color: var(--mk-warning-ink);\n border-bottom: 1px dashed var(--mk-warning-ink);\n}\n\n.mk-value--missing {\n font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;\n font-size: 0.9em;\n color: var(--mk-faint);\n font-style: italic;\n}\n\n/*\n * Failure-kind modifiers (`@markii/runtime`'s `FailureKind`, docs/scripting.md) —\n * layered on top of `.mk-value--missing`, only ever added when the\n * resolution's root entry carried a `failureKind` on a genuine error (see\n * `ValueDirective`). Each just tints the dashed-underline treatment a\n * distinct hue so a reader can tell \"script bug\" apart from \"needs\n * permission\" / \"needs a manual run\" / \"hit a resource limit\" at a glance,\n * with the full message still available via the `title` tooltip.\n *\n * Failure kinds map onto the Tier 1 semantic hues: script-error -> danger,\n * capability-denied -> warning, tier-blocked -> info, limit -> limit.\n */\n.mk-value--script-error {\n border-bottom: 1px dashed var(--mk-danger-ink);\n}\n\n.mk-value--capability-denied {\n border-bottom: 1px dashed var(--mk-warning-ink);\n}\n\n.mk-value--tier-blocked {\n border-bottom: 1px dashed var(--mk-info-ink);\n}\n\n.mk-value--limit {\n border-bottom: 1px dashed var(--mk-limit-ink);\n}\n\n/* ---------- script marker ---------- */\n\n.mk-script {\n border: 1px solid var(--mk-border);\n border-radius: 6px;\n background: var(--mk-surface);\n}\n\n.mk-script__summary {\n cursor: pointer;\n padding: 0.5rem 0.75rem;\n font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;\n font-size: 0.85em;\n color: var(--mk-muted);\n}\n\n.mk-script[open] > .mk-script__summary {\n border-bottom: 1px solid var(--mk-border);\n}\n\n.mk-script__code {\n margin: 0.75rem;\n margin-block-start: 0;\n}\n\n.mk-script__empty {\n margin: 0.75rem;\n margin-block-start: 0;\n font-size: 0.85em;\n font-style: italic;\n color: var(--mk-faint);\n}\n\n/* ---------- details ---------- */\n\n.mk-details {\n border: 1px solid var(--mk-border);\n border-radius: 6px;\n padding: 0.75rem 1rem;\n background: var(--mk-surface);\n}\n\n.mk-details__summary {\n cursor: pointer;\n font-weight: 600;\n}\n\n.mk-details[open] > .mk-details__summary {\n margin-block-end: 0.5rem;\n}\n\n/* Blockifies inline-flex/inline-block children (`stat`, `badge`, ...) so they stack — see `.mk-callout__body`'s comment above for why this is load-bearing. */\n.mk-details__body {\n display: flex;\n flex-direction: column;\n}\n\n.mk-details__body > :first-child {\n margin-block-start: 0.5rem;\n}\n\n.mk-details__body > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- card ---------- */\n\n.mk-card {\n border: 1px solid var(--mk-border);\n border-radius: 8px;\n padding: 1rem;\n background: var(--mk-raised);\n}\n\n.mk-card__title {\n font-weight: 600;\n margin-block-end: 0.5rem;\n}\n\n/* Blockifies inline-flex/inline-block children (`stat`, `badge`, ...) so they stack — see `.mk-callout__body`'s comment above for why this is load-bearing. */\n.mk-card__body {\n display: flex;\n flex-direction: column;\n}\n\n.mk-card__body > :first-child {\n margin-block-start: 0;\n}\n\n.mk-card__body > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- badge ---------- */\n\n.mk-badge {\n display: inline-block;\n vertical-align: baseline;\n line-height: 1.4;\n height: 1.4em;\n padding: 0 0.6em;\n font-size: 0.8em;\n font-weight: 600;\n border-radius: 999px;\n color: var(--mk-badge-fg, var(--mk-fg));\n background: var(--mk-badge-bg, var(--mk-surface-strong));\n}\n\n.mk-badge--info {\n --mk-badge-fg: var(--mk-info-ink);\n --mk-badge-bg: var(--mk-info-strong-fill);\n}\n\n.mk-badge--success {\n --mk-badge-fg: var(--mk-success-ink);\n --mk-badge-bg: var(--mk-success-strong-fill);\n}\n\n.mk-badge--warning {\n --mk-badge-fg: var(--mk-warning-ink);\n --mk-badge-bg: var(--mk-warning-strong-fill);\n}\n\n.mk-badge--danger {\n --mk-badge-fg: var(--mk-danger-ink);\n --mk-badge-bg: var(--mk-danger-strong-fill);\n}\n\n/* ---------- figure ---------- */\n\n.mk-figure {\n margin: 0;\n}\n\n.mk-figure__img {\n display: block;\n max-width: 100%;\n height: auto;\n border-radius: 6px;\n}\n\n.mk-figure__caption {\n margin-block-start: 0.5rem;\n font-size: 0.9em;\n color: var(--mk-muted);\n}\n\n.mk-figure__caption > :first-child {\n margin-block-start: 0;\n}\n\n.mk-figure__caption > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- tabs ---------- */\n\n.mk-tabs__list {\n display: flex;\n gap: 0.25rem;\n border-bottom: 1px solid var(--mk-border);\n}\n\n.mk-tabs__button {\n cursor: pointer;\n border: none;\n background: none;\n padding: 0.5rem 0.9rem;\n font: inherit;\n font-weight: 600;\n color: var(--mk-muted);\n border-bottom: 2px solid transparent;\n margin-block-end: -1px;\n}\n\n.mk-tabs__button--active {\n color: var(--mk-accent);\n border-bottom-color: var(--mk-accent);\n}\n\n/* Blockifies inline-flex/inline-block children (`stat`, `badge`, ...) so they stack — see `.mk-callout__body`'s comment above for why this is load-bearing. */\n.mk-tab {\n display: flex;\n flex-direction: column;\n padding-block-start: 0.75rem;\n}\n\n.mk-tab > :first-child {\n margin-block-start: 0;\n}\n\n.mk-tab > * + * {\n margin-block-start: 0.5rem;\n}\n\n/* ---------- stat ---------- */\n\n.mk-stat {\n display: inline-flex;\n flex-direction: column;\n gap: 0.15rem;\n}\n\n.mk-stat__value {\n font-size: 1.8em;\n font-weight: 700;\n line-height: 1.2;\n}\n\n.mk-stat__label {\n font-size: 0.85em;\n color: var(--mk-muted);\n}\n\n.mk-stat__delta {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--mk-muted);\n}\n\n.mk-stat__delta--up {\n color: var(--mk-success-ink);\n}\n\n.mk-stat__delta--down {\n color: var(--mk-danger-ink);\n}\n\n.mk-stat__delta--flat {\n color: var(--mk-muted);\n}\n\n/* ---------- progress ---------- */\n\n.mk-progress {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n}\n\n.mk-progress__label {\n font-size: 0.85em;\n color: var(--mk-muted);\n flex: 0 0 auto;\n}\n\n.mk-progress__track {\n flex: 1 1 auto;\n height: 0.6rem;\n border-radius: 999px;\n background: var(--mk-border);\n overflow: hidden;\n}\n\n.mk-progress__bar {\n height: 100%;\n background: var(--mk-accent);\n border-radius: inherit;\n}\n\n.mk-progress__percent {\n flex: 0 0 auto;\n font-size: 0.85em;\n font-variant-numeric: tabular-nums;\n color: var(--mk-muted);\n}\n\n/* ---------- chart ---------- */\n\n.mk-chart {\n display: block;\n max-width: 100%;\n}\n\n.mk-chart__line {\n fill: none;\n stroke: var(--mk-accent);\n stroke-width: 2;\n stroke-linejoin: round;\n stroke-linecap: round;\n}\n\n.mk-chart__bar {\n fill: var(--mk-accent);\n}\n\n.mk-chart--empty {\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.8em;\n font-style: italic;\n color: var(--mk-faint);\n border: 1px dashed var(--mk-border);\n border-radius: 6px;\n}\n\n/* ---------- table (data-bound) ---------- */\n\n/*\n * `.mk-table` is the ROOT wrapper `Table` renders (docs/format.md's\n * `::table`), holding an optional `.mk-table__caption` and the real\n * `<table class=\"mk-table__table\">`. The `<table>`/`<th>`/`<td>` elements\n * inside it are deliberately left to `.doc table`/`.doc th`/`.doc td`\n * above — the same rules a bare GFM table already gets — so `mk-table`\n * never forks or duplicates that styling; only the wrapper and its two\n * non-`<table>` children need rules of their own.\n */\n.mk-table {\n display: block;\n}\n\n.mk-table__caption {\n font-size: 0.85em;\n color: var(--mk-muted);\n margin-block-end: 0.4rem;\n}\n\n.mk-table--empty .mk-table__empty {\n font-size: 0.85em;\n font-style: italic;\n color: var(--mk-faint);\n border: 1px dashed var(--mk-border);\n border-radius: 6px;\n padding: 0.5rem 0.75rem;\n}\n\n/* ---------- data-binding state (docs/scripting.md) ---------- */\n\n/*\n * The block twins of the `.mk-value--*` markers above: a data-bound\n * component (`stat`, `progress`, `chart`) whose `data=` binding is stale or\n * failed keeps its ordinary QUIET body — `—`, a `0%` bar, `no data` — and\n * says so only through these hooks plus its `title` tooltip. No rule here\n * may add body text (`content:` is deliberately absent) or an outer margin;\n * the failure taxonomy's wording lives in one place only, `components/\n * failure-presentation.ts`.\n *\n * The hues match `.mk-value--*` exactly, so the same failing name reads the\n * same whether it surfaced inline via `:value[...]` or as a component.\n */\n.mk-stat--stale,\n.mk-progress--stale,\n.mk-chart--stale,\n.mk-table--stale {\n opacity: 0.8;\n}\n\n.mk-stat--script-error,\n.mk-progress--script-error,\n.mk-chart--script-error,\n.mk-table--script-error {\n border-bottom: 2px solid var(--mk-danger-ink);\n}\n\n.mk-stat--capability-denied,\n.mk-progress--capability-denied,\n.mk-chart--capability-denied,\n.mk-table--capability-denied {\n border-bottom: 2px solid var(--mk-warning-ink);\n}\n\n.mk-stat--tier-blocked,\n.mk-progress--tier-blocked,\n.mk-chart--tier-blocked,\n.mk-table--tier-blocked {\n border-bottom: 2px solid var(--mk-info-ink);\n}\n\n.mk-stat--limit,\n.mk-progress--limit,\n.mk-chart--limit,\n.mk-table--limit {\n border-bottom: 2px solid var(--mk-limit-ink);\n}\n\n/* ---------- layout presets (docs/format.md) ---------- */\n\n/*\n * `render.tsx` only wraps a directive in a `mk-width-*`/`mk-align-*` `<div>`\n * when at least one of these classes actually applies, so the wrapper below\n * IS the element sitting directly in `.doc`'s rhythm flow — never\n * `margin-block` here, that's `.doc > * + *`'s job alone; setting it on the\n * wrapper too would double up spacing. `max-width: min(<size>, 100%)` keeps\n * every preset from ever overflowing the document column, even on a narrow\n * viewport where the size below is wider than the column itself.\n */\n.mk-width-narrow {\n max-width: min(var(--mk-width-narrow), 100%);\n}\n\n.mk-width-wide {\n max-width: min(var(--mk-width-wide), 100%);\n}\n\n/* \"full\" is the full available column width — not a viewport-bleed hack with negative margins. */\n.mk-width-full {\n max-width: var(--mk-width-full);\n}\n\n/*\n * \"fit\" is the one preset that sets `width` rather than only capping it: it\n * shrinks the block to its own content instead of filling the column, and\n * `max-width` keeps that from overflowing when the content is wider than\n * the column. Because the box is now narrower than its container, the\n * `mk-align-*` auto margins below finally have room to work, which is what\n * makes `{width=fit align=right}` hug the content AND sit right.\n */\n.mk-width-fit {\n width: var(--mk-width-fit);\n max-width: 100%;\n}\n\n.mk-align-left {\n margin-inline-end: auto;\n}\n\n.mk-align-center {\n margin-inline: auto;\n}\n\n.mk-align-right {\n margin-inline-start: auto;\n}\n\n/*\n * `align=` with NO width preset is a documented no-op (docs/format.md): the\n * wrapper above fills its container exactly like an unwrapped block, so\n * `margin-inline: auto` has zero free space to distribute and moves\n * nothing. That is fine in normal flow, where \"no free space\" simply means\n * \"nothing visibly happens\" — but a `.mk-row`/`.mk-row--cols-*` track is a\n * GRID, and a grid item with an auto inline margin is pulled OUT of the\n * default stretch alignment regardless of whether that margin has any space\n * to consume (CSS Box Alignment's \"auto margins win over alignment\" rule),\n * so the item shrinks to its own content size instead of filling its\n * column. For a card with no body that content size is just its padding,\n * which reads as the card collapsing into itself (#56) the instant\n * `align=` is added, even though `align=` alone was never supposed to\n * change the card's size.\n *\n * These three rules restore the no-op, but only inside `.mk-row`, and only\n * when no `mk-width-*` preset also applies: the margin goes back to a\n * literal `0`, so the item has no auto margin left to suppress stretch and\n * fills its column exactly as it would with no `align=` at all. The\n * `:not()` list is what lets `{width=narrow align=right}` keep shrinking\n * and moving inside a row: that combination still matches the base rules\n * above, since a width preset means the wrapper carries a real size for the\n * auto margin to act on rather than a no-op default full-column width.\n * `.mk-layout.mk-align-*` (the `:::center`/`:::left`/`:::right` wrappers)\n * are covered by the same plain `.mk-align-*` class, since a selector on\n * one class matches an element carrying several.\n */\n.mk-row\n > .mk-align-left:not(.mk-width-fit):not(.mk-width-narrow):not(\n .mk-width-wide\n ):not(.mk-width-full),\n.mk-row\n > .mk-align-center:not(.mk-width-fit):not(.mk-width-narrow):not(\n .mk-width-wide\n ):not(.mk-width-full),\n.mk-row\n > .mk-align-right:not(.mk-width-fit):not(.mk-width-narrow):not(\n .mk-width-wide\n ):not(.mk-width-full) {\n margin-inline: 0;\n}\n\n/*\n * The `:::center`/`:::left`/`:::right`/`:::wide`/`:::narrow`/`:::full`/`:::fit`\n * layout wrappers (docs/format.md, `layout-wrapper.tsx`) reuse the `mk-width-*`/\n * `mk-align-*` classes above and add `mk-layout` on top for the rules below,\n * which only make sense on a container that has its OWN plain-markdown\n * children (a table, an image, a paragraph) rather than on the bare\n * attribute-interception wrapper `render.tsx` emits for `width=`/`align=`.\n *\n * No outer margin on `.mk-layout` itself, same rule as every component\n * (Architecture rule 4) — `.doc > * + *` spaces the wrapper against its\n * siblings. This rule instead restores RHYTHM *inside* the wrapper's own\n * scope, mirroring `.mk-card__body > * + *`: without it, the wrapper's\n * children would have no spacing between them at all, since `.doc > * + *`\n * only ever sees the wrapper `<div>` as a whole, never reaches inside it.\n */\n.mk-layout > * + * {\n margin-block-start: 1rem;\n}\n\n/*\n * An alignment wrapper additionally sets text alignment for everything in\n * scope, not just the shrink-to-fit block alignment below. `left` carries\n * its own rule for a reason: it is the one wrapper written specifically to\n * opt a scope back OUT of an alignment it inherited (a cell of a\n * `:::row{text=center}`), and only a DECLARED value beats an inherited one.\n * Without this rule `:::left` would silently keep the centered text it was\n * written to undo.\n */\n.mk-layout.mk-align-left {\n text-align: left;\n}\n\n.mk-layout.mk-align-center {\n text-align: center;\n}\n\n.mk-layout.mk-align-right {\n text-align: right;\n}\n\n/*\n * `.mk-align-center`/`.mk-align-right` above (shared with the `align=`\n * attribute wrapper) only center/right-align the wrapper `<div>` ITSELF\n * within ITS container — they say nothing about the wrapper's own children.\n * These two rules do that: they shrink-to-fit and align every direct child\n * of the wrapper's scope, which is what actually centers/right-aligns a\n * narrower-than-column table or image sitting inside `:::center`/`:::right`.\n */\n.mk-layout.mk-align-center > * {\n margin-inline: auto;\n}\n\n.mk-layout.mk-align-right > * {\n margin-inline-start: auto;\n margin-inline-end: 0;\n}\n\n/*\n * `.doc table` (above) sets `display: block` for horizontal-scroll\n * overflow, which also makes the table fill its column — defeating\n * shrink-to-fit alignment before it can even apply. These two rules size a\n * table down to its content instead, so the `margin-inline` rules above\n * have a narrower box to actually move. `.doc th`/`.doc td` set\n * `text-align: left` directly on the cells, so this scope's `text-align`\n * never flips table cell text — only the table's own position in the\n * scope, and any non-table text alongside it.\n */\n.mk-layout.mk-align-center > table,\n.mk-layout.mk-align-right > table {\n width: fit-content;\n max-width: 100%;\n}\n\n/* ---------- text alignment inside a component (docs/format.md) ---------- */\n\n/*\n * The `text` attribute of `row`, `cell`, `card`, and `callout`. Deliberately\n * separate from the `mk-align-*` classes above: those place a block's BOX\n * within the column and never touch its contents, while these align the\n * content inside one component and never move its box. Two different jobs,\n * two different class names, so neither can be mistaken for the other.\n *\n * One rule per value, defined once and honored by all four components, which\n * is what lets `:::row{text=center}` reach its cells through ordinary CSS\n * inheritance: `.mk-cell` declares no `text-align` of its own, so the row's\n * value flows in, and a cell that declares its own (or an alignment wrapper\n * written inside it) wins simply by being declared.\n */\n.mk-text-left {\n text-align: left;\n}\n\n.mk-text-center {\n text-align: center;\n}\n\n.mk-text-right {\n text-align: right;\n}\n\n/* ---------- row ---------- */\n\n.mk-row {\n display: grid;\n gap: 1rem;\n grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));\n}\n\n/*\n * `minmax(0, 1fr)` — a bare `1fr` track can still grow past an equal share\n * to fit a wide intrinsic-content cell (e.g. a `chart` SVG, a long code\n * span); pinning the minimum to `0` is what keeps such a cell, and\n * therefore the whole row, from blowing out past its column.\n */\n.mk-row--cols-2 {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n.mk-row--cols-3 {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n}\n\n.mk-row--cols-4 {\n grid-template-columns: repeat(4, minmax(0, 1fr));\n}\n\n/*\n * No separate blockification rule is needed here, unlike `.mk-card__body`'s:\n * a grid container (which `.mk-row` is) already promotes every direct\n * child's outer display to block-level as a grid item, same as a flex\n * container does — an inline-flex/inline-block component (`stat`, `badge`,\n * ...) used directly as a row cell already stacks/sizes as a proper grid\n * cell with no extra rule required.\n */\n\n@media (max-width: 40rem) {\n .mk-row,\n .mk-row--cols-2,\n .mk-row--cols-3,\n .mk-row--cols-4 {\n grid-template-columns: 1fr;\n }\n}\n\n/* ---------- cell ---------- */\n\n/*\n * `:::cell` (`cell.tsx`) is a TRANSPARENT grouping container: it has no\n * border, background, padding, or outer margin of its own — its whole job is\n * making several blocks count as ONE `.mk-row` grid cell, so anything visual\n * here would betray that. `.mk-cell` therefore has no rule at all; only the\n * rhythm-restoring rule below exists, mirroring `.mk-layout > * + *`:\n * `.doc > * + *` sees the cell as a single box and never reaches inside it,\n * so without this its children would sit flush against each other.\n */\n.mk-cell > * + * {\n margin-block-start: 1rem;\n}\n\n/* ---------- empty inline-component marker ---------- */\n\n/*\n * Wraps an `inline: true` component that received no content\n * (`::badge{label=\"x\"}` instead of `:badge[x]`) — see `render.tsx`'s\n * `isRegisteredInline`/`isEmptyContent` and `render.ts`'s HTML-engine\n * mirror. The component underneath renders unchanged; this is a quiet\n * perceptual hook only (a faint dashed underline, matching the treatment\n * `.mk-value--stale` already gives a quiet-but-present state), with the\n * reason carried in the `title` tooltip rather than in the page.\n */\n.mk-inline-empty {\n border-bottom: 1px dashed var(--mk-faint);\n}\n\n/* ---------- recognized-and-declined value marker ---------- */\n\n/*\n * The generic marker for a value the render pipeline recognized and\n * declined to use, rather than silently dropping it (AGENTS.md's \"clean is\n * not silent\"): a known attribute's value outside its closed enum, or a\n * `figure` `src` refused as unsafe (`failure-presentation.ts`'s\n * `NOTICE_ATTRIBUTE`). The component underneath renders exactly as it\n * would otherwise, wrapped in a plain `<span>` (inline directive) or\n * `<div>` (block directive) carrying this attribute and a `title` with the\n * reason. Styled as an attribute selector, not a class, so the same rule\n * applies no matter which element the wrapper turns out to be.\n */\n[data-mk-notice] {\n outline: 1px dashed var(--mk-faint);\n outline-offset: 2px;\n}\n\n/* ---------- unknown directive fallback ---------- */\n\n.mk-unknown {\n border: 1px dashed var(--mk-faint);\n border-radius: 6px;\n color: var(--mk-muted);\n}\n\n.mk-unknown--block {\n padding: 0.75rem 1rem;\n}\n\n.mk-unknown--inline {\n display: inline-flex;\n align-items: baseline;\n gap: 0.4em;\n padding: 0 0.4em;\n vertical-align: baseline;\n}\n\n.mk-unknown__label {\n font-size: 0.85em;\n font-style: italic;\n margin: 0;\n}\n\n.mk-unknown__content > :first-child {\n margin-block-start: 0.5rem;\n}\n\n.mk-unknown__content > * + * {\n margin-block-start: 0.5rem;\n}\n";
@@ -22,3 +22,15 @@ export declare function dataStateClassName(base: string, status: ValueStatus | u
22
22
  export declare const EMPTY_INLINE_MARKER_CLASS = "mk-inline-empty";
23
23
  /** The `title` tooltip for `EMPTY_INLINE_MARKER_CLASS`. Ported verbatim from `@markii/react`. */
24
24
  export declare function emptyInlineTitle(name: string): string;
25
+ /**
26
+ * The attribute both engines set on a component's own rendered output for a
27
+ * quiet marker over a recognized-and-declined value (a known attribute's
28
+ * value outside its enum, or a `figure` `src` refused as unsafe). Ported
29
+ * from `@markii/react`'s copy; see that copy's doc comment for the full
30
+ * rationale.
31
+ */
32
+ export declare const NOTICE_ATTRIBUTE = "data-mk-notice";
33
+ /** The `title` tooltip for `NOTICE_ATTRIBUTE` on an enum mismatch. Ported verbatim from `@markii/react`. */
34
+ export declare function invalidAttributeValueTitle(directive: string, attribute: string, value: string): string;
35
+ /** The `title` tooltip for `NOTICE_ATTRIBUTE` on a `figure` with a refused `src`. Ported verbatim from `@markii/react`. */
36
+ export declare function unsafeImageSrcTitle(directive: string): string;
@@ -66,3 +66,19 @@ export const EMPTY_INLINE_MARKER_CLASS = 'mk-inline-empty';
66
66
  export function emptyInlineTitle(name) {
67
67
  return `${name}: no content (an attribute may have been used where directive text was expected)`;
68
68
  }
69
+ /**
70
+ * The attribute both engines set on a component's own rendered output for a
71
+ * quiet marker over a recognized-and-declined value (a known attribute's
72
+ * value outside its enum, or a `figure` `src` refused as unsafe). Ported
73
+ * from `@markii/react`'s copy; see that copy's doc comment for the full
74
+ * rationale.
75
+ */
76
+ export const NOTICE_ATTRIBUTE = 'data-mk-notice';
77
+ /** The `title` tooltip for `NOTICE_ATTRIBUTE` on an enum mismatch. Ported verbatim from `@markii/react`. */
78
+ export function invalidAttributeValueTitle(directive, attribute, value) {
79
+ return `${directive}: "${value}" is not a valid ${attribute} value (ignored)`;
80
+ }
81
+ /** The `title` tooltip for `NOTICE_ATTRIBUTE` on a `figure` with a refused `src`. Ported verbatim from `@markii/react`. */
82
+ export function unsafeImageSrcTitle(directive) {
83
+ return `${directive}: image source was refused as unsafe and was not shown`;
84
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The link-rewrite twin of `./image-resolve.js`'s `resolveImageSrc`:
3
+ * `renderMarkToHtml`'s `resolveHref` option (`render.ts`'s
4
+ * `RenderMarkOptions`), applied to every `<a href>` an ordinary markdown
5
+ * link produces. Nothing in the standard component set builds its own
6
+ * `<a>` today, so this seam only ever touches links the parser itself
7
+ * emitted. Mirrors `@markii/react`'s `href-resolve.ts`.
8
+ *
9
+ * Same resolvability rule as images (no scheme, no protocol-relative
10
+ * `//host/...`, no bare `#fragment`, not empty), and the same
11
+ * `javascript:`/`vbscript:` refusal on the resolver's OWN return value,
12
+ * both shared with `./image-resolve.js` via `./url-resolve.js`, never
13
+ * copied, so the two seams cannot drift on what counts as a dangerous
14
+ * scheme.
15
+ */
16
+ export type ResolveHref = (href: string) => string | undefined;
17
+ /**
18
+ * The value one `<a href>` should actually carry: `value` unchanged unless
19
+ * `resolveHref` is present, `value` is worth resolving at all, the resolver
20
+ * returns something, and that something passes `isSafeResolvedUrl`. A
21
+ * resolver that throws is treated exactly like one that returned
22
+ * `undefined`: `value` is kept, and the render is never broken over one
23
+ * link.
24
+ */
25
+ export declare function resolveHrefAttribute(value: string, resolveHref: ResolveHref | undefined): string;
@@ -0,0 +1,25 @@
1
+ import { isResolvableRelativeValue, isSafeResolvedUrl } from './url-resolve.js';
2
+ /**
3
+ * The value one `<a href>` should actually carry: `value` unchanged unless
4
+ * `resolveHref` is present, `value` is worth resolving at all, the resolver
5
+ * returns something, and that something passes `isSafeResolvedUrl`. A
6
+ * resolver that throws is treated exactly like one that returned
7
+ * `undefined`: `value` is kept, and the render is never broken over one
8
+ * link.
9
+ */
10
+ export function resolveHrefAttribute(value, resolveHref) {
11
+ if (!resolveHref)
12
+ return value;
13
+ if (!isResolvableRelativeValue(value))
14
+ return value;
15
+ let resolved;
16
+ try {
17
+ resolved = resolveHref(value);
18
+ }
19
+ catch {
20
+ return value;
21
+ }
22
+ if (resolved === undefined)
23
+ return value;
24
+ return isSafeResolvedUrl(resolved) ? resolved : value;
25
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The shared logic behind `renderMarkToHtml`'s `resolveImageSrc` option
3
+ * (see `render.ts`'s `RenderMarkOptions`), used everywhere an `<img>`
4
+ * reaches the output string: an ordinary markdown image (`render.ts`'s
5
+ * `makeTransform`, which rewrites a plain hast `img` element in place) and
6
+ * the standard `Figure` component, which builds its own `<img>` HTML from a
7
+ * directive attribute rather than from parsed markdown.
8
+ *
9
+ * A host resolver is only ever asked about a source that could plausibly
10
+ * be its own: one with no scheme, no protocol-relative `//host/...` form,
11
+ * no bare `#fragment`, and no empty/whitespace value — `./url-resolve.js`'s
12
+ * `isResolvableRelativeValue`, shared with `resolveHref`
13
+ * (`./href-resolve.js`) rather than duplicated, and the identical rule
14
+ * `@markii/react`'s `image-resolve.ts` applies.
15
+ *
16
+ * WHY THE RESULT CHECK IS NOT `isSafeUrl`. `isSafeUrl`'s allowlist
17
+ * (`http`/`https`/`mailto`/`tel`) exists to judge a URL an AUTHOR typed
18
+ * into the document, where any other scheme is suspicious. A resolver's
19
+ * RETURN VALUE is the opposite trust direction: it is the HOST's own
20
+ * answer for where its resolved image actually lives, and both reference
21
+ * hosts already return values `isSafeUrl` would reject outright — VS
22
+ * Code's embedded bundle assets are `data:image/...` URIs and Obsidian's
23
+ * vault resource path is an `app://` URL (`@markii/react`'s
24
+ * `image-resolve.ts` names both call sites). Applying `isSafeUrl` here
25
+ * would blank every image either host resolves. What still needs guarding
26
+ * against is a resolver, hostile or merely buggy, echoing a
27
+ * `javascript:`/`vbscript:` value back out — the one class of scheme that
28
+ * turns an `<img src>` into a script-execution vector rather than an image
29
+ * request. `./url-resolve.js`'s `isSafeResolvedUrl` is a narrow denylist
30
+ * for exactly that, not a repeat of the author-facing allowlist. Matches
31
+ * `@markii/react`'s identical function so the two engines cannot diverge.
32
+ */
33
+ /** The shape `renderMarkToHtml`/`renderMarkNodeToHtml` accept, and the one carried on `HtmlRenderContext` for a component that builds its own `<img>`. */
34
+ export type ResolveImageSrc = (src: string) => string | undefined;
35
+ /**
36
+ * The value one `<img src>` should actually carry: `value` unchanged unless
37
+ * `resolveImageSrc` is present, `value` is worth resolving at all, the
38
+ * resolver returns something, and that something passes
39
+ * `isSafeResolvedUrl` — so a resolver can never smuggle a
40
+ * `javascript:` URL past the sanitizer that already ran on everything else
41
+ * in the document, while a legitimate `data:`/`app:`/host-scheme result
42
+ * still reaches the page. A resolver that throws is treated exactly like
43
+ * one that returned `undefined`: `value` is kept, and the render is never
44
+ * broken over one image.
45
+ */
46
+ export declare function resolveImageAttribute(value: string, resolveImageSrc: ResolveImageSrc | undefined): string;
@@ -0,0 +1,28 @@
1
+ import { isResolvableRelativeValue, isSafeResolvedUrl } from './url-resolve.js';
2
+ /**
3
+ * The value one `<img src>` should actually carry: `value` unchanged unless
4
+ * `resolveImageSrc` is present, `value` is worth resolving at all, the
5
+ * resolver returns something, and that something passes
6
+ * `isSafeResolvedUrl` — so a resolver can never smuggle a
7
+ * `javascript:` URL past the sanitizer that already ran on everything else
8
+ * in the document, while a legitimate `data:`/`app:`/host-scheme result
9
+ * still reaches the page. A resolver that throws is treated exactly like
10
+ * one that returned `undefined`: `value` is kept, and the render is never
11
+ * broken over one image.
12
+ */
13
+ export function resolveImageAttribute(value, resolveImageSrc) {
14
+ if (!resolveImageSrc)
15
+ return value;
16
+ if (!isResolvableRelativeValue(value))
17
+ return value;
18
+ let resolved;
19
+ try {
20
+ resolved = resolveImageSrc(value);
21
+ }
22
+ catch {
23
+ return value;
24
+ }
25
+ if (resolved === undefined)
26
+ return value;
27
+ return isSafeResolvedUrl(resolved) ? resolved : value;
28
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- export { renderMarkToHtml, renderMarkNodeToHtml } from './render.js';
1
+ export { renderMarkToHtml, renderMarkNodeToHtml, renderMarkInlineToHtml, type RenderMarkOptions, } from './render.js';
2
+ export { type ResolveImageSrc } from './image-resolve.js';
3
+ export { type ResolveHref } from './href-resolve.js';
2
4
  export { escapeHtml } from './escape.js';
3
5
  export { exportHtmlDocument, type ExportHtmlDocumentOptions, } from './document.js';
4
6
  export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, type StorePathResolution, type ValueScope, } from './resolve.js';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // stopped-changing document can be rendered for publishing, CI, email, or an
4
4
  // archive with no React runtime. It is one platform renderer among possible
5
5
  // many; the React renderer (@markii/react) is another consumer of the same core.
6
- export { renderMarkToHtml, renderMarkNodeToHtml } from './render.js';
6
+ export { renderMarkToHtml, renderMarkNodeToHtml, renderMarkInlineToHtml, } from './render.js';
7
7
  export { escapeHtml } from './escape.js';
8
8
  export { exportHtmlDocument, } from './document.js';
9
9
  export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, } from './resolve.js';
@@ -7,7 +7,8 @@
7
7
  * the React renderer's, so a note resolves the same way in both.
8
8
  */
9
9
  import type { FailureKind, ValueStatus } from '@markii/runtime';
10
- import type { LayoutAxis } from '@markii/stdlib';
10
+ import type { LayoutAxis, OnDiagnostic } from '@markii/stdlib';
11
+ import type { ResolveImageSrc } from './image-resolve.js';
11
12
  /**
12
13
  * Attributes parsed off a directive, e.g. `{type=warning title="Careful"}`. A
13
14
  * bare attribute (present but valueless, e.g. `{collapsed}`) arrives as
@@ -81,6 +82,26 @@ export interface HtmlRenderContext {
81
82
  * arguments and has no room for a fourth.
82
83
  */
83
84
  layoutClassName?: string;
85
+ /**
86
+ * `renderMarkToHtml`'s `resolveImageSrc` option (`render.ts`'s
87
+ * `RenderMarkOptions`), carried on `ctx` so a component that builds its
88
+ * own `<img>` from an attribute — the standard `Figure` is the only one
89
+ * today — can resolve it the same way an ordinary markdown image does.
90
+ * `undefined` when the render call supplied none, in which case a
91
+ * component must leave its `src` exactly as authored.
92
+ */
93
+ resolveImageSrc?: ResolveImageSrc;
94
+ /**
95
+ * `renderMarkToHtml`'s `onDiagnostic` option (`render.ts`'s
96
+ * `RenderMarkOptions`), carried on `ctx` so a component that decides for
97
+ * itself whether to render a quiet marker — the standard `Figure` is the
98
+ * only one today, for a `src` refused as unsafe — can report the same
99
+ * event a host would otherwise only see as an in-page `title` tooltip.
100
+ * `undefined` when the render call supplied none. Never call this
101
+ * directly without going through `@markii/stdlib`'s `reportDiagnostic`,
102
+ * which guards against a throwing callback.
103
+ */
104
+ onDiagnostic?: OnDiagnostic;
84
105
  }
85
106
  /**
86
107
  * One registry component: receives the directive's raw string attributes
package/dist/render.d.ts CHANGED
@@ -1,6 +1,17 @@
1
+ import { parse } from '@markii/core';
1
2
  import type { MarkNode } from '@markii/core';
2
3
  import type { ValueStore, VaultStore } from '@markii/runtime';
3
4
  import type { HtmlRegistry } from './registry.js';
5
+ import type { OnDiagnostic } from '@markii/stdlib';
6
+ import type { ResolveImageSrc } from './image-resolve.js';
7
+ import type { ResolveHref } from './href-resolve.js';
8
+ /**
9
+ * A whole parsed document, as `@markii/core`'s `parse` returns it — the
10
+ * type `renderMarkNodeToHtml` accepts alongside a single `MarkNode` (#44a).
11
+ * Named locally rather than importing `mdast`'s `Root` directly, mirroring
12
+ * `@markii/react`'s identical `MarkRoot` type alias.
13
+ */
14
+ type MarkRoot = ReturnType<typeof parse>;
4
15
  /**
5
16
  * Renders Markii text to a static HTML string using `registry` to resolve
6
17
  * directive names. Pipeline: `@markii/core`'s `toHast` (parse -> tag directive
@@ -22,13 +33,73 @@ import type { HtmlRegistry } from './registry.js';
22
33
  * mine, `@name` = the vault's". With no `vault` supplied, every `@name`
23
34
  * degrades to `'missing'` the same way an absent `store` degrades a bare
24
35
  * name.
36
+ *
37
+ * `options.resolveImageSrc` resolves a relative `<img src>` — an ordinary
38
+ * markdown image or one `Figure` built from an attribute — to a URL a host
39
+ * can actually load. It is never asked about a source that already carries
40
+ * a scheme, a protocol-relative `//host/...`, a bare `#fragment`, or an
41
+ * empty value, and its result is re-checked against a narrow
42
+ * `javascript:`/`vbscript:` denylist before use (`./url-resolve.js`), so it
43
+ * cannot introduce a scheme the parser's own sanitizer would otherwise have
44
+ * dropped. Returning `undefined`, or throwing, leaves the source exactly as
45
+ * the author wrote it. Omitted entirely, every image renders with the
46
+ * source unchanged, matching every render before this option existed.
47
+ *
48
+ * `options.resolveHref` (#44b) is the identical seam for an ordinary
49
+ * markdown link's `<a href>`: same resolvability rule, same shared
50
+ * dangerous-scheme refusal, same fall-through-on-`undefined`-or-throw
51
+ * behavior. No standard component builds its own `<a>`, so this only ever
52
+ * touches a link the parser itself produced.
53
+ *
54
+ * `options.onDiagnostic`, when supplied, is called once for every quiet
55
+ * in-page marker this render produces for a value it recognized and
56
+ * declined to use outright — a known attribute's value outside its closed
57
+ * enum, or a `figure` `src` refused as unsafe — with
58
+ * `{ kind, directive, attribute, message }` (`@markii/stdlib`'s
59
+ * `DiagnosticEvent`), so a host can put the same information on its own
60
+ * diagnostics surface. Never called for an unknown-attribute NAME or for
61
+ * the unknown-directive/form-mismatch fallbacks, which already show
62
+ * themselves in the page. A callback that throws can never break the
63
+ * render: every call site goes through `@markii/stdlib`'s
64
+ * `reportDiagnostic`. Both options mirror `@markii/react`'s identical
65
+ * `RenderMarkOptions`, so the two engines cannot diverge.
66
+ */
67
+ export interface RenderMarkOptions {
68
+ readonly resolveImageSrc?: ResolveImageSrc;
69
+ readonly resolveHref?: ResolveHref;
70
+ readonly onDiagnostic?: OnDiagnostic;
71
+ /**
72
+ * The note's own last-run values, the same thing the third positional
73
+ * parameter takes. Offered here as well so a caller that already builds
74
+ * an options object does not have to fill positional slots it has no
75
+ * other use for. When both forms are given the option wins.
76
+ */
77
+ readonly store?: ValueStore;
78
+ /** Values other notes published, read by an `@`-prefixed name. Same positional twin and same precedence as `store`. */
79
+ readonly vault?: VaultStore;
80
+ }
81
+ export declare function renderMarkToHtml(text: string, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore, options?: RenderMarkOptions): string;
82
+ /**
83
+ * The block-level twin of `renderMarkToHtml`: renders one already-parsed
84
+ * mdast node OR a whole already-parsed mdast document (`@markii/core`'s
85
+ * `MarkNode`, or the `Root` `parse` itself returns; #44a) to HTML instead of
86
+ * raw document text, via `nodeOrRootToHast`. Same registry resolution, same
87
+ * fallbacks, same purity and never-throw guarantees, and the same optional
88
+ * `store`/`vault` value-binding arguments and `resolveImageSrc`/
89
+ * `resolveHref`/`onDiagnostic` options.
90
+ *
91
+ * A `MarkNode` and a `Root` share ONE parameter, mirroring
92
+ * `@markii/react`'s `renderMarkNode` (see that function's doc comment for
93
+ * why: the smaller surface for callers, no second near-identical export).
25
94
  */
26
- export declare function renderMarkToHtml(text: string, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore): string;
95
+ export declare function renderMarkNodeToHtml(node: MarkNode | MarkRoot, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore, options?: RenderMarkOptions): string;
27
96
  /**
28
- * The block-level twin of `renderMarkToHtml`: renders one already-parsed mdast
29
- * node (`@markii/core`'s `MarkNode`) to HTML instead of a whole document's
30
- * text, via `nodeToHast`. Same registry resolution, same fallbacks, same
31
- * purity and never-throw guarantees, and the same optional `store`/`vault`
32
- * value-binding arguments.
97
+ * Renders `text` as a single, standalone inline directive when that is all
98
+ * it is (#44c), mirroring `@markii/react`'s identical `renderMarkInline`:
99
+ * source whose only parsed block is a paragraph holding exactly one text
100
+ * directive renders that directive alone, WITHOUT the paragraph wrapper
101
+ * `renderMarkToHtml` would otherwise put around it. Any other source falls
102
+ * back to the exact same rendering `renderMarkToHtml` would produce.
33
103
  */
34
- export declare function renderMarkNodeToHtml(node: MarkNode, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore): string;
104
+ export declare function renderMarkInlineToHtml(text: string, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore, options?: RenderMarkOptions): string;
105
+ export {};
package/dist/render.js CHANGED
@@ -1,11 +1,31 @@
1
- import { toHast, nodeToHast, parseMetaAttributes, isValidScriptName, isBareAttribute, } from '@markii/core';
1
+ import { parse, toHast, nodeToHast, parseMetaAttributes, isValidScriptName, isBareAttribute, } from '@markii/core';
2
2
  import { toHtml } from 'hast-util-to-html';
3
3
  import { readRegistryComponent, registryLayoutAxis, resolveDirectiveAlias, } from './registry.js';
4
4
  import { resolveLayoutAttributes } from './layout.js';
5
5
  import { escapeHtml } from './escape.js';
6
6
  import { resolveScopedPath } from './resolve.js';
7
- import { failureKindClass, failureTitle, EMPTY_INLINE_MARKER_CLASS, emptyInlineTitle, } from './failure-presentation.js';
8
- import { formatValue } from '@markii/stdlib';
7
+ import { failureKindClass, failureTitle, EMPTY_INLINE_MARKER_CLASS, emptyInlineTitle, invalidAttributeValueTitle, } from './failure-presentation.js';
8
+ import { formatValue, getContract, reportDiagnostic, INTERACTIVE_ATTRIBUTE, } from '@markii/stdlib';
9
+ import { resolveImageAttribute } from './image-resolve.js';
10
+ import { resolveHrefAttribute } from './href-resolve.js';
11
+ function isMarkRoot(node) {
12
+ return node.type === 'root';
13
+ }
14
+ /**
15
+ * Converts a `MarkNode | MarkRoot` (#44a) to a sanitized hast tree via
16
+ * `@markii/core`'s `nodeToHast`, mirroring `@markii/react`'s identical
17
+ * `nodeOrRootToHast`: for a whole `MarkRoot`, each child is run through
18
+ * `nodeToHast` independently and the resulting hast children concatenated.
19
+ */
20
+ function nodeOrRootToHast(node) {
21
+ if (!isMarkRoot(node))
22
+ return nodeToHast(node);
23
+ const children = [];
24
+ for (const child of node.children) {
25
+ children.push(...nodeToHast(child).children);
26
+ }
27
+ return { type: 'root', children };
28
+ }
9
29
  /** The hast tag name `@markii/core`'s `toHast` marks every directive with (`to-hast.ts`'s `DIRECTIVE_TAG`). */
10
30
  const DIRECTIVE_TAG = 'mk-directive';
11
31
  /** `data-mk-kind` value for a TEXT (inline) directive; the other two kinds (`leafDirective`/`containerDirective`) are block. */
@@ -64,7 +84,7 @@ function buildValueMarker(name, resolved, format, decimals) {
64
84
  * The `data*` fields are attached per-directive later (see
65
85
  * `withDataBinding`) — this base object never carries them.
66
86
  */
67
- function createBaseContext(scope) {
87
+ function createBaseContext(scope, resolveImageSrc, onDiagnostic) {
68
88
  return {
69
89
  esc: escapeHtml,
70
90
  resolve(name) {
@@ -80,6 +100,8 @@ function createBaseContext(scope) {
80
100
  : { value: undefined, status: 'missing' };
81
101
  return buildValueMarker(trimmed, resolved, format, decimals);
82
102
  },
103
+ resolveImageSrc,
104
+ onDiagnostic,
83
105
  };
84
106
  }
85
107
  /**
@@ -313,13 +335,35 @@ function renderScriptMarker(node) {
313
335
  ? `<pre class="mk-script__code"><code>${escapeHtml(code)}</code></pre>`
314
336
  : `<p class="mk-script__empty">${src ? `source: ${escapeHtml(src)}` : 'no inline body'}</p>`;
315
337
  return (`<details class="mk-script"${open ? ' open' : ''}>` +
316
- `<summary class="mk-script__summary">${escapeHtml(summary)}</summary>` +
338
+ `<summary class="mk-script__summary" ${INTERACTIVE_ATTRIBUTE}="">${escapeHtml(summary)}</summary>` +
317
339
  `${body}</details>`);
318
340
  }
319
341
  catch {
320
342
  return undefined;
321
343
  }
322
344
  }
345
+ /**
346
+ * Whether one of `name`'s `@markii/stdlib` contract attributes with a
347
+ * closed `enum` is present in `attributes` with a value outside that enum.
348
+ * Returns the first offending attribute/value pair found, or `undefined`.
349
+ * Mirrors `@markii/react`'s identical `invalidEnumAttribute`.
350
+ */
351
+ function invalidEnumAttribute(name, attributes) {
352
+ const contract = getContract(name);
353
+ if (!contract)
354
+ return undefined;
355
+ for (const [attribute, schema] of Object.entries(contract.attributes)) {
356
+ const enumValues = schema.enum;
357
+ if (!enumValues)
358
+ continue;
359
+ const value = attributes[attribute];
360
+ if (value === undefined || value === null || value === '')
361
+ continue;
362
+ if (!enumValues.includes(value))
363
+ return { attribute, value };
364
+ }
365
+ return undefined;
366
+ }
323
367
  /** Resolves one directive (registry component, `:value[...]`, or the fallback) given its layout-stripped attributes. Never throws. */
324
368
  function renderDirectiveContent(name, kind, attributes, childrenHtml, plainLabel, registry, ctx, scope, layoutClassName) {
325
369
  if (name === VALUE_DIRECTIVE_NAME) {
@@ -342,6 +386,25 @@ function renderDirectiveContent(name, kind, attributes, childrenHtml, plainLabel
342
386
  catch {
343
387
  return componentError(name || '(unnamed)', inline, childrenHtml);
344
388
  }
389
+ // The silent-value-drop mechanism (AGENTS.md "clean is not silent"),
390
+ // mirroring `@markii/react`'s identical check: a known attribute's value
391
+ // outside its closed enum still renders the component exactly as
392
+ // registered, wrapped in a quiet marker whose `title` carries the reason,
393
+ // and `onDiagnostic` gets the same event for a host's own diagnostics
394
+ // surface. Checked against the FINAL attributes the component actually
395
+ // received (`binding.attributes`), not the raw ones.
396
+ const invalidEnum = invalidEnumAttribute(name, binding.attributes);
397
+ if (invalidEnum) {
398
+ const message = invalidAttributeValueTitle(name, invalidEnum.attribute, invalidEnum.value);
399
+ reportDiagnostic(ctx.onDiagnostic, {
400
+ kind: 'invalid-attribute-value',
401
+ directive: name,
402
+ attribute: invalidEnum.attribute,
403
+ message,
404
+ });
405
+ const tag = inline ? 'span' : 'div';
406
+ rendered = `<${tag} data-mk-notice="" title="${escapeHtml(message)}">${rendered}</${tag}>`;
407
+ }
345
408
  // ITEM 1 (AGENTS.md "clean is not silent"): mirrors `@markii/react`'s
346
409
  // identical check in `renderDirectiveContent` — an `inline: true`
347
410
  // component with no content still renders exactly as registered, wrapped
@@ -386,7 +449,33 @@ function renderDirective(element, registry, ctx, scope) {
386
449
  * children so a nested directive is already resolved by the time its parent
387
450
  * serializes it.
388
451
  */
389
- function makeTransform(registry, ctx, scope) {
452
+ /**
453
+ * Rewrites an ordinary hast `<img>` element's `src` in place through
454
+ * `resolveImageSrc` — the plain-markdown-image half of the seam
455
+ * `Figure` implements for its own attribute-built `<img>` (see
456
+ * `./components/figure.ts`). With no resolver at all (the common case)
457
+ * this is a no-op, so a fixture rendered without one produces byte-
458
+ * identical output to before this option existed.
459
+ */
460
+ function applyImageResolver(node, resolveImageSrc) {
461
+ const src = node.properties.src;
462
+ if (typeof src === 'string') {
463
+ node.properties.src = resolveImageAttribute(src, resolveImageSrc);
464
+ }
465
+ }
466
+ /**
467
+ * Rewrites an ordinary hast `<a>` element's `href` in place through
468
+ * `resolveHref` (#44b) — the link-rewrite twin of `applyImageResolver`. No
469
+ * standard component builds its own `<a>`, so this is the only place a link
470
+ * href is ever resolved. With no resolver at all this is a no-op.
471
+ */
472
+ function applyHrefResolver(node, resolveHref) {
473
+ const href = node.properties.href;
474
+ if (typeof href === 'string') {
475
+ node.properties.href = resolveHrefAttribute(href, resolveHref);
476
+ }
477
+ }
478
+ function makeTransform(registry, ctx, scope, resolveHref) {
390
479
  function transform(node) {
391
480
  if (node.type !== 'element')
392
481
  return node;
@@ -398,6 +487,12 @@ function makeTransform(registry, ctx, scope) {
398
487
  if (marker !== undefined)
399
488
  return raw(marker);
400
489
  }
490
+ if (node.tagName === 'img') {
491
+ applyImageResolver(node, ctx.resolveImageSrc);
492
+ }
493
+ if (node.tagName === 'a') {
494
+ applyHrefResolver(node, resolveHref);
495
+ }
401
496
  return node;
402
497
  }
403
498
  return transform;
@@ -409,54 +504,86 @@ function renderFailureFallback(error) {
409
504
  `<p class="mk-unknown__label">failed to render document</p>` +
410
505
  `<pre class="mk-unknown__content">${escapeHtml(message)}</pre></div>`);
411
506
  }
412
- function renderRoot(root, registry, scope) {
413
- const ctx = createBaseContext(scope);
414
- const transform = makeTransform(registry, ctx, scope);
507
+ function renderRoot(root, registry, scope, resolveImageSrc, resolveHref, onDiagnostic) {
508
+ const ctx = createBaseContext(scope, resolveImageSrc, onDiagnostic);
509
+ const transform = makeTransform(registry, ctx, scope, resolveHref);
415
510
  root.children = root.children.map(transform);
416
511
  return serialize(root.children);
417
512
  }
513
+ export function renderMarkToHtml(text, registry, store, vault, options) {
514
+ try {
515
+ return renderRoot(toHast(text), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic);
516
+ }
517
+ catch (error) {
518
+ return renderFailureFallback(error);
519
+ }
520
+ }
418
521
  /**
419
- * Renders Markii text to a static HTML string using `registry` to resolve
420
- * directive names. Pipeline: `@markii/core`'s `toHast` (parse -> tag directive
421
- * nodes -> remark-rehype -> sanitize URLs) -> a hast->HTML walk that swaps
422
- * directive elements for registry components (or the unknown-directive
423
- * fallback) and folds script fences into markers. Pure and never-throwing:
424
- * parsing is tolerant, unknown names always render a fallback, and any
425
- * unexpected internal error degrades to the "failed to render document" box.
426
- *
427
- * `store` is the note's value store (`@markii/runtime`, §8's pure read path)
428
- * — optional, matching how a missing/absent value degrades gracefully: with
429
- * no store, `:value[name]` renders its missing-value marker and every
430
- * `data=name` attribute resolves to `dataStatus: 'missing'`, but the
431
- * document still renders completely.
522
+ * The block-level twin of `renderMarkToHtml`: renders one already-parsed
523
+ * mdast node OR a whole already-parsed mdast document (`@markii/core`'s
524
+ * `MarkNode`, or the `Root` `parse` itself returns; #44a) to HTML instead of
525
+ * raw document text, via `nodeOrRootToHast`. Same registry resolution, same
526
+ * fallbacks, same purity and never-throw guarantees, and the same optional
527
+ * `store`/`vault` value-binding arguments and `resolveImageSrc`/
528
+ * `resolveHref`/`onDiagnostic` options.
432
529
  *
433
- * `vault` is the optional app-scoped read seam (`@markii/runtime`'s
434
- * `VaultStore`) that an `@`-prefixed name (`data=@gh.stars`,
435
- * `:value[@gh.stars]`) resolves against instead of `store` "bare name =
436
- * mine, `@name` = the vault's". With no `vault` supplied, every `@name`
437
- * degrades to `'missing'` the same way an absent `store` degrades a bare
438
- * name.
530
+ * A `MarkNode` and a `Root` share ONE parameter, mirroring
531
+ * `@markii/react`'s `renderMarkNode` (see that function's doc comment for
532
+ * why: the smaller surface for callers, no second near-identical export).
439
533
  */
440
- export function renderMarkToHtml(text, registry, store, vault) {
534
+ export function renderMarkNodeToHtml(node, registry, store, vault, options) {
441
535
  try {
442
- return renderRoot(toHast(text), registry, { store, vault });
536
+ return renderRoot(nodeOrRootToHast(node), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic);
443
537
  }
444
538
  catch (error) {
445
539
  return renderFailureFallback(error);
446
540
  }
447
541
  }
448
542
  /**
449
- * The block-level twin of `renderMarkToHtml`: renders one already-parsed mdast
450
- * node (`@markii/core`'s `MarkNode`) to HTML instead of a whole document's
451
- * text, via `nodeToHast`. Same registry resolution, same fallbacks, same
452
- * purity and never-throw guarantees, and the same optional `store`/`vault`
453
- * value-binding arguments.
543
+ * Whether `root` is exactly one paragraph holding exactly one text
544
+ * directive (`:name[...]`) and nothing else, mirroring `@markii/react`'s
545
+ * identical `loneInlineDirective`. Returns that inner directive node when
546
+ * so, `undefined` otherwise.
454
547
  */
455
- export function renderMarkNodeToHtml(node, registry, store, vault) {
548
+ function loneInlineDirective(root) {
549
+ if (root.children.length !== 1)
550
+ return undefined;
551
+ const [only] = root.children;
552
+ if (!only || only.type !== 'paragraph')
553
+ return undefined;
554
+ if (only.children.length !== 1)
555
+ return undefined;
556
+ const [inner] = only.children;
557
+ // Compared against a widened `{ type?: unknown }` view, not
558
+ // `PhrasingContent` directly: `'textDirective'` only joins that union when
559
+ // something in the compilation imports `mdast-util-directive`'s ambient
560
+ // augmentation, which this file has no other reason to do. Matches
561
+ // `isMarkRoot`'s identical widen-then-compare shape just above, and
562
+ // `@markii/react`'s identical fix in its own copy of this function.
563
+ return inner && inner.type === 'textDirective'
564
+ ? inner
565
+ : undefined;
566
+ }
567
+ /**
568
+ * Renders `text` as a single, standalone inline directive when that is all
569
+ * it is (#44c), mirroring `@markii/react`'s identical `renderMarkInline`:
570
+ * source whose only parsed block is a paragraph holding exactly one text
571
+ * directive renders that directive alone, WITHOUT the paragraph wrapper
572
+ * `renderMarkToHtml` would otherwise put around it. Any other source falls
573
+ * back to the exact same rendering `renderMarkToHtml` would produce.
574
+ */
575
+ export function renderMarkInlineToHtml(text, registry, store, vault, options) {
456
576
  try {
457
- return renderRoot(nodeToHast(node), registry, { store, vault });
577
+ const root = parse(text);
578
+ const lone = loneInlineDirective(root);
579
+ if (lone) {
580
+ return renderMarkNodeToHtml(lone, registry, store, vault, options);
581
+ }
458
582
  }
459
- catch (error) {
460
- return renderFailureFallback(error);
583
+ catch {
584
+ // Falls through to the ordinary whole-document render below, whose own
585
+ // try/catch produces the shared failure fallback if parsing (again) or
586
+ // rendering fails.
461
587
  }
588
+ return renderMarkToHtml(text, registry, store, vault, options);
462
589
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The scheme-safety logic shared by every host-resolver seam on this
3
+ * engine's `RenderMarkOptions`: `resolveImageSrc` (`./image-resolve.js`)
4
+ * and `resolveHref` (`./href-resolve.js`). Both seams follow the identical
5
+ * shape: offer a host resolver a value that looks like ITS OWN relative
6
+ * path, then refuse a `javascript:`/`vbscript:` scheme in whatever the
7
+ * resolver hands back. That logic lives here ONCE and each seam's module
8
+ * only adds the one-line wrapper that names its own option
9
+ * (`resolveImageSrc` vs `resolveHref`). See `./image-resolve.js`'s top
10
+ * comment for the full rationale: why the result is checked against a
11
+ * narrow denylist here, not `@markii/core`'s author-facing `isSafeUrl`
12
+ * allowlist. Mirrors `@markii/react`'s `url-resolve.ts` so the two engines
13
+ * cannot diverge on what counts as a dangerous scheme.
14
+ */
15
+ /**
16
+ * The scheme text before the first `:` when one is present in scheme
17
+ * position, lowercased; `undefined` for a schemeless value. Delimiter rule
18
+ * matches `@markii/core`'s `isSafeUrl`.
19
+ */
20
+ export declare function schemeOf(value: string): string | undefined;
21
+ /**
22
+ * True for a value worth offering to a resolver at all: no scheme, no
23
+ * protocol-relative `//host/...` form, no bare `#fragment`, not
24
+ * empty/whitespace.
25
+ */
26
+ export declare function isResolvableRelativeValue(value: string): boolean;
27
+ /**
28
+ * Schemes that turn a resolved URL into a script-execution vector.
29
+ * Everything else a resolver returns (`https:`, `data:`, `app:`, a host's
30
+ * own custom scheme) is a legitimate resolved location, not a smuggled
31
+ * script.
32
+ */
33
+ export declare const DANGEROUS_URL_SCHEMES: Set<string>;
34
+ /**
35
+ * `value` reduced to what a browser will actually parse a scheme out of:
36
+ * ASCII tab, line feed and carriage return removed wherever they appear,
37
+ * then leading C0 controls and spaces stripped. The URL parser ignores
38
+ * exactly these, so a tab or newline spliced into the middle of a scheme
39
+ * name, or leading whitespace/control characters before it, both still
40
+ * reach the page as that scheme. A scheme test that reads the raw text
41
+ * instead would call both of them schemeless and wave them through, which
42
+ * is the difference between a denylist that holds and one that only looks
43
+ * like it does.
44
+ */
45
+ export declare function forSchemeTest(value: string): string;
46
+ /**
47
+ * True unless `value` carries one of `DANGEROUS_URL_SCHEMES`, judged
48
+ * against `forSchemeTest`'s browser-equivalent reading rather than the raw
49
+ * string. Because this IS a denylist, an unrecognized scheme is allowed, so
50
+ * the parsing it rests on has to match the browser's exactly: an allowlist
51
+ * fails closed on a spelling it does not recognize, and this cannot.
52
+ */
53
+ export declare function isSafeResolvedUrl(value: string): boolean;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The scheme-safety logic shared by every host-resolver seam on this
3
+ * engine's `RenderMarkOptions`: `resolveImageSrc` (`./image-resolve.js`)
4
+ * and `resolveHref` (`./href-resolve.js`). Both seams follow the identical
5
+ * shape: offer a host resolver a value that looks like ITS OWN relative
6
+ * path, then refuse a `javascript:`/`vbscript:` scheme in whatever the
7
+ * resolver hands back. That logic lives here ONCE and each seam's module
8
+ * only adds the one-line wrapper that names its own option
9
+ * (`resolveImageSrc` vs `resolveHref`). See `./image-resolve.js`'s top
10
+ * comment for the full rationale: why the result is checked against a
11
+ * narrow denylist here, not `@markii/core`'s author-facing `isSafeUrl`
12
+ * allowlist. Mirrors `@markii/react`'s `url-resolve.ts` so the two engines
13
+ * cannot diverge on what counts as a dangerous scheme.
14
+ */
15
+ /**
16
+ * The scheme text before the first `:` when one is present in scheme
17
+ * position, lowercased; `undefined` for a schemeless value. Delimiter rule
18
+ * matches `@markii/core`'s `isSafeUrl`.
19
+ */
20
+ export function schemeOf(value) {
21
+ const colon = value.indexOf(':');
22
+ if (colon === -1)
23
+ return undefined;
24
+ const slash = value.indexOf('/');
25
+ const questionMark = value.indexOf('?');
26
+ const numberSign = value.indexOf('#');
27
+ const hasSchemeBeforeDelimiter = (slash === -1 || colon < slash) &&
28
+ (questionMark === -1 || colon < questionMark) &&
29
+ (numberSign === -1 || colon < numberSign);
30
+ return hasSchemeBeforeDelimiter
31
+ ? value.slice(0, colon).toLowerCase()
32
+ : undefined;
33
+ }
34
+ /**
35
+ * True for a value worth offering to a resolver at all: no scheme, no
36
+ * protocol-relative `//host/...` form, no bare `#fragment`, not
37
+ * empty/whitespace.
38
+ */
39
+ export function isResolvableRelativeValue(value) {
40
+ if (value.trim() === '')
41
+ return false;
42
+ if (value.startsWith('#'))
43
+ return false;
44
+ if (value.startsWith('//'))
45
+ return false;
46
+ return schemeOf(value) === undefined;
47
+ }
48
+ /**
49
+ * Schemes that turn a resolved URL into a script-execution vector.
50
+ * Everything else a resolver returns (`https:`, `data:`, `app:`, a host's
51
+ * own custom scheme) is a legitimate resolved location, not a smuggled
52
+ * script.
53
+ */
54
+ export const DANGEROUS_URL_SCHEMES = new Set(['javascript', 'vbscript']);
55
+ const TAB_NEWLINE_CR = new RegExp('[\\u0009\\u000a\\u000d]', 'g');
56
+ const LEADING_C0_OR_SPACE = new RegExp('^[\\u0000-\\u0020]+');
57
+ /**
58
+ * `value` reduced to what a browser will actually parse a scheme out of:
59
+ * ASCII tab, line feed and carriage return removed wherever they appear,
60
+ * then leading C0 controls and spaces stripped. The URL parser ignores
61
+ * exactly these, so a tab or newline spliced into the middle of a scheme
62
+ * name, or leading whitespace/control characters before it, both still
63
+ * reach the page as that scheme. A scheme test that reads the raw text
64
+ * instead would call both of them schemeless and wave them through, which
65
+ * is the difference between a denylist that holds and one that only looks
66
+ * like it does.
67
+ */
68
+ export function forSchemeTest(value) {
69
+ return value.replace(TAB_NEWLINE_CR, '').replace(LEADING_C0_OR_SPACE, '');
70
+ }
71
+ /**
72
+ * True unless `value` carries one of `DANGEROUS_URL_SCHEMES`, judged
73
+ * against `forSchemeTest`'s browser-equivalent reading rather than the raw
74
+ * string. Because this IS a denylist, an unrecognized scheme is allowed, so
75
+ * the parsing it rests on has to match the browser's exactly: an allowlist
76
+ * fails closed on a spelling it does not recognize, and this cannot.
77
+ */
78
+ export function isSafeResolvedUrl(value) {
79
+ const scheme = schemeOf(forSchemeTest(value));
80
+ return scheme === undefined || !DANGEROUS_URL_SCHEMES.has(scheme);
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/html",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "A framework-free static HTML renderer for Markii (.mk.md): a registry-driven hast-to-HTML string engine. Zero React; for stopped-changing documents (publish, CI, email, archive).",
5
5
  "keywords": [
6
6
  "markdown",
@@ -54,9 +54,9 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "hast-util-to-html": "^9.0.0",
57
- "@markii/core": "^0.12.1",
58
- "@markii/runtime": "^0.12.1",
59
- "@markii/stdlib": "^0.12.1"
57
+ "@markii/core": "^0.14.0",
58
+ "@markii/runtime": "^0.14.0",
59
+ "@markii/stdlib": "^0.14.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/hast": "^3.0.4"