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