@markii/html 0.12.0 → 0.13.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.
- package/dist/components/figure.d.ts +6 -0
- package/dist/components/figure.js +11 -1
- package/dist/doc-css.generated.js +1 -1
- package/dist/image-resolve.d.ts +52 -0
- package/dist/image-resolve.js +120 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/registry.d.ts +10 -0
- package/dist/render.d.ts +20 -3
- package/dist/render.js +27 -30
- package/package.json +4 -4
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import { isSafeUrl } from '@markii/core';
|
|
2
|
+
import { resolveImageAttribute } from '../image-resolve.js';
|
|
2
3
|
const DEFAULT_ALT = '';
|
|
3
4
|
/**
|
|
4
5
|
* `:::figure{src="..." alt="..."} caption markdown :::` — an image with a
|
|
@@ -15,11 +16,20 @@ const DEFAULT_ALT = '';
|
|
|
15
16
|
* exact same allowlist check the sanitizer uses) and dropping the image
|
|
16
17
|
* entirely when it fails, rather than re-implementing URL-scheme parsing
|
|
17
18
|
* here. Matches `@markii/react`'s `Figure` markup byte-for-byte.
|
|
19
|
+
*
|
|
20
|
+
* `ctx.resolveImageSrc` (`../render.js`'s `renderMarkToHtml` option) then
|
|
21
|
+
* gets the same chance at an already-safe `src` that an ordinary markdown
|
|
22
|
+
* image gets (`../render.js`'s `applyImageResolver`), so a host resolving
|
|
23
|
+
* relative images sees this component's picture too, not just the ones
|
|
24
|
+
* markdown itself wrote.
|
|
18
25
|
*/
|
|
19
26
|
export const Figure = (attributes, childrenHtml, ctx) => {
|
|
20
27
|
const rawSrc = attributes.src ?? null;
|
|
21
28
|
const alt = attributes.alt ?? DEFAULT_ALT;
|
|
22
|
-
const
|
|
29
|
+
const safeSrc = rawSrc && isSafeUrl(rawSrc) ? rawSrc : null;
|
|
30
|
+
const src = safeSrc
|
|
31
|
+
? resolveImageAttribute(safeSrc, ctx.resolveImageSrc)
|
|
32
|
+
: null;
|
|
23
33
|
const imgHtml = src
|
|
24
34
|
? `<img class="mk-figure__img" src="${ctx.esc(src)}" alt="${ctx.esc(alt)}">`
|
|
25
35
|
: '';
|
|
@@ -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)\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@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/*\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.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";
|
|
@@ -0,0 +1,52 @@
|
|
|
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. Everything else
|
|
12
|
+
* already resolves on its own (or is not a path at all) and is left
|
|
13
|
+
* exactly as written — the identical rule `@markii/react`'s
|
|
14
|
+
* `image-resolve.ts` applies, so the two engines cannot diverge on what
|
|
15
|
+
* counts as "relative".
|
|
16
|
+
*
|
|
17
|
+
* The scheme test mirrors `@markii/core`'s `isSafeUrl`: text before the
|
|
18
|
+
* first `:`, but only when that `:` precedes any `/`, `?`, or `#` — so a
|
|
19
|
+
* path that merely contains a colon later on (`notes/a:b.png`) still reads
|
|
20
|
+
* as relative.
|
|
21
|
+
*
|
|
22
|
+
* WHY THE RESULT CHECK IS NOT `isSafeUrl`. `isSafeUrl`'s allowlist
|
|
23
|
+
* (`http`/`https`/`mailto`/`tel`) exists to judge a URL an AUTHOR typed
|
|
24
|
+
* into the document, where any other scheme is suspicious. A resolver's
|
|
25
|
+
* RETURN VALUE is the opposite trust direction: it is the HOST's own
|
|
26
|
+
* answer for where its resolved image actually lives, and both reference
|
|
27
|
+
* hosts already return values `isSafeUrl` would reject outright — VS
|
|
28
|
+
* Code's embedded bundle assets are `data:image/...` URIs and Obsidian's
|
|
29
|
+
* vault resource path is an `app://` URL (`@markii/react`'s
|
|
30
|
+
* `image-resolve.ts` names both call sites). Applying `isSafeUrl` here
|
|
31
|
+
* would blank every image either host resolves. What still needs guarding
|
|
32
|
+
* against is a resolver, hostile or merely buggy, echoing a
|
|
33
|
+
* `javascript:`/`vbscript:` value back out — the one class of scheme that
|
|
34
|
+
* turns an `<img src>` into a script-execution vector rather than an image
|
|
35
|
+
* request. `isSafeResolvedImageSrc` below is a narrow denylist for exactly
|
|
36
|
+
* that, not a repeat of the author-facing allowlist. Matches
|
|
37
|
+
* `@markii/react`'s identical function so the two engines cannot diverge.
|
|
38
|
+
*/
|
|
39
|
+
/** The shape `renderMarkToHtml`/`renderMarkNodeToHtml` accept, and the one carried on `HtmlRenderContext` for a component that builds its own `<img>`. */
|
|
40
|
+
export type ResolveImageSrc = (src: string) => string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The value one `<img src>` should actually carry: `value` unchanged unless
|
|
43
|
+
* `resolveImageSrc` is present, `value` is worth resolving at all, the
|
|
44
|
+
* resolver returns something, and that something passes
|
|
45
|
+
* `isSafeResolvedImageSrc` — so a resolver can never smuggle a
|
|
46
|
+
* `javascript:` URL past the sanitizer that already ran on everything else
|
|
47
|
+
* in the document, while a legitimate `data:`/`app:`/host-scheme result
|
|
48
|
+
* still reaches the page. A resolver that throws is treated exactly like
|
|
49
|
+
* one that returned `undefined`: `value` is kept, and the render is never
|
|
50
|
+
* broken over one image.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveImageAttribute(value: string, resolveImageSrc: ResolveImageSrc | undefined): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
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. Everything else
|
|
12
|
+
* already resolves on its own (or is not a path at all) and is left
|
|
13
|
+
* exactly as written — the identical rule `@markii/react`'s
|
|
14
|
+
* `image-resolve.ts` applies, so the two engines cannot diverge on what
|
|
15
|
+
* counts as "relative".
|
|
16
|
+
*
|
|
17
|
+
* The scheme test mirrors `@markii/core`'s `isSafeUrl`: text before the
|
|
18
|
+
* first `:`, but only when that `:` precedes any `/`, `?`, or `#` — so a
|
|
19
|
+
* path that merely contains a colon later on (`notes/a:b.png`) still reads
|
|
20
|
+
* as relative.
|
|
21
|
+
*
|
|
22
|
+
* WHY THE RESULT CHECK IS NOT `isSafeUrl`. `isSafeUrl`'s allowlist
|
|
23
|
+
* (`http`/`https`/`mailto`/`tel`) exists to judge a URL an AUTHOR typed
|
|
24
|
+
* into the document, where any other scheme is suspicious. A resolver's
|
|
25
|
+
* RETURN VALUE is the opposite trust direction: it is the HOST's own
|
|
26
|
+
* answer for where its resolved image actually lives, and both reference
|
|
27
|
+
* hosts already return values `isSafeUrl` would reject outright — VS
|
|
28
|
+
* Code's embedded bundle assets are `data:image/...` URIs and Obsidian's
|
|
29
|
+
* vault resource path is an `app://` URL (`@markii/react`'s
|
|
30
|
+
* `image-resolve.ts` names both call sites). Applying `isSafeUrl` here
|
|
31
|
+
* would blank every image either host resolves. What still needs guarding
|
|
32
|
+
* against is a resolver, hostile or merely buggy, echoing a
|
|
33
|
+
* `javascript:`/`vbscript:` value back out — the one class of scheme that
|
|
34
|
+
* turns an `<img src>` into a script-execution vector rather than an image
|
|
35
|
+
* request. `isSafeResolvedImageSrc` below is a narrow denylist for exactly
|
|
36
|
+
* that, not a repeat of the author-facing allowlist. Matches
|
|
37
|
+
* `@markii/react`'s identical function so the two engines cannot diverge.
|
|
38
|
+
*/
|
|
39
|
+
/** The scheme text before the first `:` when one is present in scheme position, lowercased; `undefined` for a schemeless value. Delimiter rule matches `@markii/core`'s `isSafeUrl`. */
|
|
40
|
+
function schemeOf(value) {
|
|
41
|
+
const colon = value.indexOf(':');
|
|
42
|
+
if (colon === -1)
|
|
43
|
+
return undefined;
|
|
44
|
+
const slash = value.indexOf('/');
|
|
45
|
+
const questionMark = value.indexOf('?');
|
|
46
|
+
const numberSign = value.indexOf('#');
|
|
47
|
+
const hasSchemeBeforeDelimiter = (slash === -1 || colon < slash) &&
|
|
48
|
+
(questionMark === -1 || colon < questionMark) &&
|
|
49
|
+
(numberSign === -1 || colon < numberSign);
|
|
50
|
+
return hasSchemeBeforeDelimiter
|
|
51
|
+
? value.slice(0, colon).toLowerCase()
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
/** True for a source worth offering to a resolver at all. */
|
|
55
|
+
function isResolvableImageSrc(value) {
|
|
56
|
+
if (value.trim() === '')
|
|
57
|
+
return false;
|
|
58
|
+
if (value.startsWith('#'))
|
|
59
|
+
return false;
|
|
60
|
+
if (value.startsWith('//'))
|
|
61
|
+
return false;
|
|
62
|
+
return schemeOf(value) === undefined;
|
|
63
|
+
}
|
|
64
|
+
/** Schemes that turn an `<img src>` into a script-execution vector. Everything else a resolver returns — `https:`, `data:`, `app:`, a host's own custom scheme — is a legitimate resolved location, not a smuggled script. */
|
|
65
|
+
const DANGEROUS_IMAGE_SCHEMES = new Set(['javascript', 'vbscript']);
|
|
66
|
+
/**
|
|
67
|
+
* `value` reduced to what a browser will actually parse a scheme out of:
|
|
68
|
+
* ASCII tab, line feed and carriage return removed wherever they appear,
|
|
69
|
+
* then leading C0 controls and spaces stripped. The URL parser ignores
|
|
70
|
+
* exactly these, so `"java<TAB>script:alert(1)"` and `" javascript:alert(1)"`
|
|
71
|
+
* both reach the page as the `javascript:` scheme. A scheme test that reads
|
|
72
|
+
* the raw text instead would call both of them schemeless and wave them
|
|
73
|
+
* through, which is the difference between a denylist that holds and one
|
|
74
|
+
* that only looks like it does.
|
|
75
|
+
*/
|
|
76
|
+
function forSchemeTest(value) {
|
|
77
|
+
return value
|
|
78
|
+
.replace(/[\u0009\u000a\u000d]/g, '')
|
|
79
|
+
.replace(/^[\u0000-\u0020]+/, '');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* True unless `value` carries one of `DANGEROUS_IMAGE_SCHEMES`, judged
|
|
83
|
+
* against `forSchemeTest`'s browser-equivalent reading rather than the raw
|
|
84
|
+
* string. See this module's top comment for why this is a narrow denylist
|
|
85
|
+
* and not `@markii/core`'s author-facing `isSafeUrl` allowlist. Because it
|
|
86
|
+
* IS a denylist, an unrecognized scheme is allowed, so the parsing it rests
|
|
87
|
+
* on has to match the browser's exactly: an allowlist fails closed on a
|
|
88
|
+
* spelling it does not recognize, and this cannot.
|
|
89
|
+
*/
|
|
90
|
+
function isSafeResolvedImageSrc(value) {
|
|
91
|
+
const scheme = schemeOf(forSchemeTest(value));
|
|
92
|
+
return scheme === undefined || !DANGEROUS_IMAGE_SCHEMES.has(scheme);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The value one `<img src>` should actually carry: `value` unchanged unless
|
|
96
|
+
* `resolveImageSrc` is present, `value` is worth resolving at all, the
|
|
97
|
+
* resolver returns something, and that something passes
|
|
98
|
+
* `isSafeResolvedImageSrc` — so a resolver can never smuggle a
|
|
99
|
+
* `javascript:` URL past the sanitizer that already ran on everything else
|
|
100
|
+
* in the document, while a legitimate `data:`/`app:`/host-scheme result
|
|
101
|
+
* still reaches the page. A resolver that throws is treated exactly like
|
|
102
|
+
* one that returned `undefined`: `value` is kept, and the render is never
|
|
103
|
+
* broken over one image.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveImageAttribute(value, resolveImageSrc) {
|
|
106
|
+
if (!resolveImageSrc)
|
|
107
|
+
return value;
|
|
108
|
+
if (!isResolvableImageSrc(value))
|
|
109
|
+
return value;
|
|
110
|
+
let resolved;
|
|
111
|
+
try {
|
|
112
|
+
resolved = resolveImageSrc(value);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
if (resolved === undefined)
|
|
118
|
+
return value;
|
|
119
|
+
return isSafeResolvedImageSrc(resolved) ? resolved : value;
|
|
120
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { renderMarkToHtml, renderMarkNodeToHtml } from './render.js';
|
|
1
|
+
export { renderMarkToHtml, renderMarkNodeToHtml, type RenderMarkOptions, } from './render.js';
|
|
2
|
+
export { type ResolveImageSrc } from './image-resolve.js';
|
|
2
3
|
export { escapeHtml } from './escape.js';
|
|
3
4
|
export { exportHtmlDocument, type ExportHtmlDocumentOptions, } from './document.js';
|
|
4
5
|
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, } 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';
|
package/dist/registry.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { FailureKind, ValueStatus } from '@markii/runtime';
|
|
10
10
|
import type { LayoutAxis } 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,15 @@ 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;
|
|
84
94
|
}
|
|
85
95
|
/**
|
|
86
96
|
* One registry component: receives the directive's raw string attributes
|
package/dist/render.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MarkNode } from '@markii/core';
|
|
2
2
|
import type { ValueStore, VaultStore } from '@markii/runtime';
|
|
3
3
|
import type { HtmlRegistry } from './registry.js';
|
|
4
|
+
import type { ResolveImageSrc } from './image-resolve.js';
|
|
4
5
|
/**
|
|
5
6
|
* Renders Markii text to a static HTML string using `registry` to resolve
|
|
6
7
|
* directive names. Pipeline: `@markii/core`'s `toHast` (parse -> tag directive
|
|
@@ -22,13 +23,29 @@ import type { HtmlRegistry } from './registry.js';
|
|
|
22
23
|
* mine, `@name` = the vault's". With no `vault` supplied, every `@name`
|
|
23
24
|
* degrades to `'missing'` the same way an absent `store` degrades a bare
|
|
24
25
|
* name.
|
|
26
|
+
*
|
|
27
|
+
* `options.resolveImageSrc` resolves a relative `<img src>` — an ordinary
|
|
28
|
+
* markdown image or one `Figure` built from an attribute — to a URL a host
|
|
29
|
+
* can actually load. It is never asked about a source that already carries
|
|
30
|
+
* a scheme, a protocol-relative `//host/...`, a bare `#fragment`, or an
|
|
31
|
+
* empty value, and its result is re-checked against `@markii/core`'s
|
|
32
|
+
* `isSafeUrl` before use, so it cannot introduce a `javascript:` URL the
|
|
33
|
+
* sanitizer would otherwise have dropped. Returning `undefined`, or
|
|
34
|
+
* throwing, leaves the source exactly as the author wrote it. Omitted
|
|
35
|
+
* entirely, every image renders with the source unchanged, matching every
|
|
36
|
+
* render before this option existed. The identical option on
|
|
37
|
+
* `@markii/react`'s `renderMark` uses the same rules, so the two engines
|
|
38
|
+
* cannot diverge on what a resolver is offered or how its result is used.
|
|
25
39
|
*/
|
|
26
|
-
export
|
|
40
|
+
export interface RenderMarkOptions {
|
|
41
|
+
readonly resolveImageSrc?: ResolveImageSrc;
|
|
42
|
+
}
|
|
43
|
+
export declare function renderMarkToHtml(text: string, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore, options?: RenderMarkOptions): string;
|
|
27
44
|
/**
|
|
28
45
|
* The block-level twin of `renderMarkToHtml`: renders one already-parsed mdast
|
|
29
46
|
* node (`@markii/core`'s `MarkNode`) to HTML instead of a whole document's
|
|
30
47
|
* text, via `nodeToHast`. Same registry resolution, same fallbacks, same
|
|
31
48
|
* purity and never-throw guarantees, and the same optional `store`/`vault`
|
|
32
|
-
* value-binding arguments.
|
|
49
|
+
* value-binding arguments and `resolveImageSrc` option.
|
|
33
50
|
*/
|
|
34
|
-
export declare function renderMarkNodeToHtml(node: MarkNode, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore): string;
|
|
51
|
+
export declare function renderMarkNodeToHtml(node: MarkNode, registry: HtmlRegistry, store?: ValueStore, vault?: VaultStore, options?: RenderMarkOptions): string;
|
package/dist/render.js
CHANGED
|
@@ -6,6 +6,7 @@ import { escapeHtml } from './escape.js';
|
|
|
6
6
|
import { resolveScopedPath } from './resolve.js';
|
|
7
7
|
import { failureKindClass, failureTitle, EMPTY_INLINE_MARKER_CLASS, emptyInlineTitle, } from './failure-presentation.js';
|
|
8
8
|
import { formatValue } from '@markii/stdlib';
|
|
9
|
+
import { resolveImageAttribute } from './image-resolve.js';
|
|
9
10
|
/** The hast tag name `@markii/core`'s `toHast` marks every directive with (`to-hast.ts`'s `DIRECTIVE_TAG`). */
|
|
10
11
|
const DIRECTIVE_TAG = 'mk-directive';
|
|
11
12
|
/** `data-mk-kind` value for a TEXT (inline) directive; the other two kinds (`leafDirective`/`containerDirective`) are block. */
|
|
@@ -64,7 +65,7 @@ function buildValueMarker(name, resolved, format, decimals) {
|
|
|
64
65
|
* The `data*` fields are attached per-directive later (see
|
|
65
66
|
* `withDataBinding`) — this base object never carries them.
|
|
66
67
|
*/
|
|
67
|
-
function createBaseContext(scope) {
|
|
68
|
+
function createBaseContext(scope, resolveImageSrc) {
|
|
68
69
|
return {
|
|
69
70
|
esc: escapeHtml,
|
|
70
71
|
resolve(name) {
|
|
@@ -80,6 +81,7 @@ function createBaseContext(scope) {
|
|
|
80
81
|
: { value: undefined, status: 'missing' };
|
|
81
82
|
return buildValueMarker(trimmed, resolved, format, decimals);
|
|
82
83
|
},
|
|
84
|
+
resolveImageSrc,
|
|
83
85
|
};
|
|
84
86
|
}
|
|
85
87
|
/**
|
|
@@ -386,6 +388,20 @@ function renderDirective(element, registry, ctx, scope) {
|
|
|
386
388
|
* children so a nested directive is already resolved by the time its parent
|
|
387
389
|
* serializes it.
|
|
388
390
|
*/
|
|
391
|
+
/**
|
|
392
|
+
* Rewrites an ordinary hast `<img>` element's `src` in place through
|
|
393
|
+
* `resolveImageSrc` — the plain-markdown-image half of the seam
|
|
394
|
+
* `Figure` implements for its own attribute-built `<img>` (see
|
|
395
|
+
* `./components/figure.ts`). With no resolver at all (the common case)
|
|
396
|
+
* this is a no-op, so a fixture rendered without one produces byte-
|
|
397
|
+
* identical output to before this option existed.
|
|
398
|
+
*/
|
|
399
|
+
function applyImageResolver(node, resolveImageSrc) {
|
|
400
|
+
const src = node.properties.src;
|
|
401
|
+
if (typeof src === 'string') {
|
|
402
|
+
node.properties.src = resolveImageAttribute(src, resolveImageSrc);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
389
405
|
function makeTransform(registry, ctx, scope) {
|
|
390
406
|
function transform(node) {
|
|
391
407
|
if (node.type !== 'element')
|
|
@@ -398,6 +414,9 @@ function makeTransform(registry, ctx, scope) {
|
|
|
398
414
|
if (marker !== undefined)
|
|
399
415
|
return raw(marker);
|
|
400
416
|
}
|
|
417
|
+
if (node.tagName === 'img') {
|
|
418
|
+
applyImageResolver(node, ctx.resolveImageSrc);
|
|
419
|
+
}
|
|
401
420
|
return node;
|
|
402
421
|
}
|
|
403
422
|
return transform;
|
|
@@ -409,37 +428,15 @@ function renderFailureFallback(error) {
|
|
|
409
428
|
`<p class="mk-unknown__label">failed to render document</p>` +
|
|
410
429
|
`<pre class="mk-unknown__content">${escapeHtml(message)}</pre></div>`);
|
|
411
430
|
}
|
|
412
|
-
function renderRoot(root, registry, scope) {
|
|
413
|
-
const ctx = createBaseContext(scope);
|
|
431
|
+
function renderRoot(root, registry, scope, resolveImageSrc) {
|
|
432
|
+
const ctx = createBaseContext(scope, resolveImageSrc);
|
|
414
433
|
const transform = makeTransform(registry, ctx, scope);
|
|
415
434
|
root.children = root.children.map(transform);
|
|
416
435
|
return serialize(root.children);
|
|
417
436
|
}
|
|
418
|
-
|
|
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.
|
|
432
|
-
*
|
|
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.
|
|
439
|
-
*/
|
|
440
|
-
export function renderMarkToHtml(text, registry, store, vault) {
|
|
437
|
+
export function renderMarkToHtml(text, registry, store, vault, options) {
|
|
441
438
|
try {
|
|
442
|
-
return renderRoot(toHast(text), registry, { store, vault });
|
|
439
|
+
return renderRoot(toHast(text), registry, { store, vault }, options?.resolveImageSrc);
|
|
443
440
|
}
|
|
444
441
|
catch (error) {
|
|
445
442
|
return renderFailureFallback(error);
|
|
@@ -450,11 +447,11 @@ export function renderMarkToHtml(text, registry, store, vault) {
|
|
|
450
447
|
* node (`@markii/core`'s `MarkNode`) to HTML instead of a whole document's
|
|
451
448
|
* text, via `nodeToHast`. Same registry resolution, same fallbacks, same
|
|
452
449
|
* purity and never-throw guarantees, and the same optional `store`/`vault`
|
|
453
|
-
* value-binding arguments.
|
|
450
|
+
* value-binding arguments and `resolveImageSrc` option.
|
|
454
451
|
*/
|
|
455
|
-
export function renderMarkNodeToHtml(node, registry, store, vault) {
|
|
452
|
+
export function renderMarkNodeToHtml(node, registry, store, vault, options) {
|
|
456
453
|
try {
|
|
457
|
-
return renderRoot(nodeToHast(node), registry, { store, vault });
|
|
454
|
+
return renderRoot(nodeToHast(node), registry, { store, vault }, options?.resolveImageSrc);
|
|
458
455
|
}
|
|
459
456
|
catch (error) {
|
|
460
457
|
return renderFailureFallback(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/html",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.
|
|
58
|
-
"@markii/runtime": "0.
|
|
59
|
-
"@markii/stdlib": "0.
|
|
57
|
+
"@markii/core": "^0.13.0",
|
|
58
|
+
"@markii/runtime": "^0.13.0",
|
|
59
|
+
"@markii/stdlib": "^0.13.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/hast": "^3.0.4"
|