@markii/html 0.6.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.
Files changed (55) hide show
  1. package/dist/components/badge.d.ts +9 -0
  2. package/dist/components/badge.js +24 -0
  3. package/dist/components/callout.d.ts +10 -0
  4. package/dist/components/callout.js +29 -0
  5. package/dist/components/card.d.ts +9 -0
  6. package/dist/components/card.js +15 -0
  7. package/dist/components/cell.d.ts +9 -0
  8. package/dist/components/cell.js +10 -0
  9. package/dist/components/chart.d.ts +15 -0
  10. package/dist/components/chart.js +158 -0
  11. package/dist/components/details.d.ts +10 -0
  12. package/dist/components/details.js +16 -0
  13. package/dist/components/figure.d.ts +18 -0
  14. package/dist/components/figure.js +28 -0
  15. package/dist/components/index.d.ts +27 -0
  16. package/dist/components/index.js +91 -0
  17. package/dist/components/kbd.d.ts +7 -0
  18. package/dist/components/kbd.js +8 -0
  19. package/dist/components/layout-wrapper.d.ts +23 -0
  20. package/dist/components/layout-wrapper.js +45 -0
  21. package/dist/components/progress.d.ts +13 -0
  22. package/dist/components/progress.js +79 -0
  23. package/dist/components/rating.d.ts +9 -0
  24. package/dist/components/rating.js +33 -0
  25. package/dist/components/row.d.ts +9 -0
  26. package/dist/components/row.js +19 -0
  27. package/dist/components/stat.d.ts +18 -0
  28. package/dist/components/stat.js +81 -0
  29. package/dist/components/tab.d.ts +18 -0
  30. package/dist/components/tab.js +19 -0
  31. package/dist/components/tabs.d.ts +30 -0
  32. package/dist/components/tabs.js +33 -0
  33. package/dist/doc-css.generated.d.ts +2 -0
  34. package/dist/doc-css.generated.js +5 -0
  35. package/dist/document.d.ts +42 -0
  36. package/dist/document.js +44 -0
  37. package/dist/escape.d.ts +7 -0
  38. package/dist/escape.js +23 -0
  39. package/dist/failure-presentation.d.ts +14 -0
  40. package/dist/failure-presentation.js +56 -0
  41. package/dist/index.d.ts +8 -0
  42. package/dist/index.js +13 -0
  43. package/dist/layout.d.ts +31 -0
  44. package/dist/layout.js +75 -0
  45. package/dist/registry.d.ts +139 -0
  46. package/dist/registry.js +118 -0
  47. package/dist/render.d.ts +34 -0
  48. package/dist/render.js +417 -0
  49. package/dist/resolve.d.ts +59 -0
  50. package/dist/resolve.js +154 -0
  51. package/dist/test/html-context.d.ts +9 -0
  52. package/dist/test/html-context.js +21 -0
  53. package/dist/value-format.d.ts +12 -0
  54. package/dist/value-format.js +41 -0
  55. package/package.json +64 -0
package/dist/render.js ADDED
@@ -0,0 +1,417 @@
1
+ import { toHast, nodeToHast, parseMetaAttributes, isValidScriptName, isBareAttribute, } from '@markii/core';
2
+ import { toHtml } from 'hast-util-to-html';
3
+ import { readRegistryComponent, resolveDirectiveAlias } from './registry.js';
4
+ import { resolveLayoutAttributes } from './layout.js';
5
+ import { escapeHtml } from './escape.js';
6
+ import { resolveScopedPath } from './resolve.js';
7
+ import { failureKindClass, failureTitle } from './failure-presentation.js';
8
+ import { stringifyStoredValue } from './value-format.js';
9
+ /** The hast tag name `@markii/core`'s `toHast` marks every directive with (`to-hast.ts`'s `DIRECTIVE_TAG`). */
10
+ const DIRECTIVE_TAG = 'mk-directive';
11
+ /** `data-mk-kind` value for a TEXT (inline) directive; the other two kinds (`leafDirective`/`containerDirective`) are block. */
12
+ const TEXT_DIRECTIVE_KIND = 'textDirective';
13
+ /** The reserved built-in for render-time value interpolation (§8: `:value[name]`). */
14
+ const VALUE_DIRECTIVE_NAME = 'value';
15
+ /** The one attribute key that binds a directive to the value store rather than passing through (§8: `data=name`). */
16
+ const DATA_ATTRIBUTE_KEY = 'data';
17
+ /** The attribute `@markii/core` preserves a code fence's raw `meta` string onto (`to-hast.ts`'s `preserveCodeMeta`). */
18
+ const CODE_META_ATTR = 'data-mk-meta';
19
+ /** The bare-only meta attribute that opens a script marker expanded (§8; bare-only, fail-closed). */
20
+ const OPEN_ATTRIBUTE_KEY = 'open';
21
+ /**
22
+ * Wraps a finished HTML string as a hast `raw` node. A `raw` node is not in
23
+ * the standard `hast` content unions, but `hast-util-to-html` emits its value
24
+ * verbatim under `allowDangerousHtml` — so every directive, script marker, and
25
+ * fallback the engine produces re-enters the tree this way, and a single
26
+ * `toHtml` pass serializes the plain hast around them while their already-built
27
+ * HTML passes through untouched.
28
+ */
29
+ function raw(value) {
30
+ return { type: 'raw', value };
31
+ }
32
+ /** Serializes a run of (already-transformed) hast content to an HTML string, passing `raw` nodes through verbatim. */
33
+ function serialize(children) {
34
+ const root = { type: 'root', children };
35
+ return toHtml(root, { allowDangerousHtml: true });
36
+ }
37
+ /**
38
+ * Builds the missing/stale/resolved `<span>` for a resolved value name,
39
+ * matching `@markii/react`'s `ValueDirective` markup and class names
40
+ * byte-for-byte: `mk-value mk-value--missing` (+ a failure-kind modifier
41
+ * class, only for a genuine `'error'` resolution) with `{name}` as the
42
+ * label when nothing resolved; `mk-value` (+ `mk-value--stale`) with the
43
+ * stringified value otherwise. Never throws.
44
+ */
45
+ function buildValueMarker(name, resolved) {
46
+ if (resolved.status === 'missing' || resolved.status === 'error') {
47
+ const failureKind = resolved.status === 'error' ? resolved.failureKind : undefined;
48
+ const kindClass = failureKindClass('mk-value', failureKind);
49
+ const className = kindClass
50
+ ? `mk-value mk-value--missing ${kindClass}`
51
+ : 'mk-value mk-value--missing';
52
+ const title = failureTitle(resolved.error, failureKind);
53
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
54
+ const label = name ? `{${escapeHtml(name)}}` : '{value}';
55
+ return `<span class="${className}"${titleAttr}>${label}</span>`;
56
+ }
57
+ const className = resolved.status === 'stale' ? 'mk-value mk-value--stale' : 'mk-value';
58
+ return `<span class="${className}">${escapeHtml(stringifyStoredValue(resolved.value))}</span>`;
59
+ }
60
+ /**
61
+ * Builds the base `HtmlRenderContext` for one top-level render call, bound
62
+ * to `scope` (the store/vault this render was invoked with). `resolve` and
63
+ * `valueMarker` never throw, matching `resolveScopedPath`'s own contract.
64
+ * The `data*` fields are attached per-directive later (see
65
+ * `withDataBinding`) — this base object never carries them.
66
+ */
67
+ function createBaseContext(scope) {
68
+ return {
69
+ esc: escapeHtml,
70
+ resolve(name) {
71
+ const trimmed = name.trim();
72
+ if (!trimmed)
73
+ return { value: undefined, status: 'missing' };
74
+ return resolveScopedPath(scope, trimmed);
75
+ },
76
+ valueMarker(name) {
77
+ const trimmed = name.trim();
78
+ const resolved = trimmed
79
+ ? resolveScopedPath(scope, trimmed)
80
+ : { value: undefined, status: 'missing' };
81
+ return buildValueMarker(trimmed, resolved);
82
+ },
83
+ };
84
+ }
85
+ /** `ctx` with one directive's resolved `data=` binding attached, for the single component invocation that binding belongs to. */
86
+ function withDataBinding(ctx, binding) {
87
+ if (!('data' in binding))
88
+ return ctx;
89
+ return {
90
+ ...ctx,
91
+ data: binding.data,
92
+ dataStatus: binding.dataStatus,
93
+ dataError: binding.dataError,
94
+ dataFailureKind: binding.dataFailureKind,
95
+ };
96
+ }
97
+ /**
98
+ * Splits a `data=<name>` attribute (if present) off `attributes`, resolves
99
+ * `<name>` against `scope` — dotted paths and `@`-prefixed vault names both
100
+ * work, via `resolveScopedPath` — and returns the resolved binding plus the
101
+ * remaining attributes. Never throws: no store/vault, an empty `data`
102
+ * attribute, or an unresolved path all degrade to `dataStatus: 'missing'`
103
+ * with `data: undefined`. Mirrors `@markii/react`'s `resolveDataAttribute`.
104
+ */
105
+ function resolveDataAttribute(attributes, scope) {
106
+ if (!Object.hasOwn(attributes, DATA_ATTRIBUTE_KEY)) {
107
+ return { attributes };
108
+ }
109
+ const { [DATA_ATTRIBUTE_KEY]: rawName, ...rest } = attributes;
110
+ if (!rawName) {
111
+ return { attributes: rest, data: undefined, dataStatus: 'missing' };
112
+ }
113
+ const resolved = resolveScopedPath(scope, rawName);
114
+ return {
115
+ attributes: rest,
116
+ data: resolved.value,
117
+ dataStatus: resolved.status,
118
+ dataError: resolved.error,
119
+ dataFailureKind: resolved.status === 'error' ? resolved.failureKind : undefined,
120
+ };
121
+ }
122
+ /** Parses the `data-mk-attrs` JSON back into an attribute map, keeping only string/null values. Never throws. */
123
+ function parseAttributes(json) {
124
+ if (!json)
125
+ return {};
126
+ try {
127
+ const parsed = JSON.parse(json);
128
+ if (parsed === null ||
129
+ typeof parsed !== 'object' ||
130
+ Array.isArray(parsed)) {
131
+ return {};
132
+ }
133
+ const result = {};
134
+ for (const [key, value] of Object.entries(parsed)) {
135
+ if (typeof value === 'string' || value === null || value === undefined) {
136
+ result[key] = value;
137
+ }
138
+ }
139
+ return result;
140
+ }
141
+ catch {
142
+ return {};
143
+ }
144
+ }
145
+ /** A directive property read back as a string, or `undefined` if absent or non-string. */
146
+ function stringProperty(element, name) {
147
+ const value = element.properties?.[name];
148
+ return typeof value === 'string' ? value : undefined;
149
+ }
150
+ /**
151
+ * Whether `entry` is a BLOCK component written inline (docs/spec.md's
152
+ * one-directional form/kind mismatch). Only an explicit `inline: false`
153
+ * counts; a throwing `inline` getter fails permissive, identical to an absent
154
+ * flag, so hostile registry configuration can never throw out of the render.
155
+ */
156
+ function isFormMismatch(entry, kind) {
157
+ if (kind !== TEXT_DIRECTIVE_KIND)
158
+ return false;
159
+ try {
160
+ return entry.inline === false;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ /** The fallback label line, worded for the reason and the form the directive was written in. Returns HTML. */
167
+ function fallbackLabel(name, inline, reason) {
168
+ const code = `<code>${escapeHtml(name)}</code>`;
169
+ if (reason === 'form-mismatch') {
170
+ return inline
171
+ ? `block component ${code} written inline`
172
+ : `inline component ${code} written as a block`;
173
+ }
174
+ return `unknown component ${code}`;
175
+ }
176
+ /**
177
+ * The unknown-directive fallback, matching `@markii/react`'s `UnknownDirective`
178
+ * markup and class names byte-for-byte so one stylesheet covers both renderers.
179
+ * An inline directive is built from `<span>`s (it lives inside a paragraph); a
180
+ * block directive gets the dashed `<div>` box with its inner content preserved.
181
+ */
182
+ function unknownDirective(name, inline, childrenHtml, reason) {
183
+ const reasonClass = reason === 'form-mismatch' ? ' mk-unknown--mismatch' : '';
184
+ const label = fallbackLabel(name, inline, reason);
185
+ if (inline) {
186
+ return (`<span class="mk-unknown mk-unknown--inline${reasonClass}">` +
187
+ `<span class="mk-unknown__label">${label}</span>` +
188
+ `${childrenHtml}</span>`);
189
+ }
190
+ const content = childrenHtml
191
+ ? `<div class="mk-unknown__content">${childrenHtml}</div>`
192
+ : '';
193
+ return (`<div class="mk-unknown mk-unknown--block${reasonClass}">` +
194
+ `<p class="mk-unknown__label">${label}</p>${content}</div>`);
195
+ }
196
+ /**
197
+ * A registered component that threw while rendering. `@markii/react` lets a
198
+ * third-party component's throw reach the host's error boundary; a string
199
+ * engine has none, so a throw is contained here to a neutral box rather than
200
+ * failing the whole document. Never advertised as "unknown" (it is registered)
201
+ * and never dumps the error text into the page (cleanliness principle).
202
+ */
203
+ function componentError(name, inline, childrenHtml) {
204
+ const code = `<code>${escapeHtml(name)}</code>`;
205
+ if (inline) {
206
+ return (`<span class="mk-unknown mk-unknown--inline mk-unknown--error">` +
207
+ `<span class="mk-unknown__label">component ${code} failed to render</span>` +
208
+ `${childrenHtml}</span>`);
209
+ }
210
+ const content = childrenHtml
211
+ ? `<div class="mk-unknown__content">${childrenHtml}</div>`
212
+ : '';
213
+ return (`<div class="mk-unknown mk-unknown--block mk-unknown--error">` +
214
+ `<p class="mk-unknown__label">component ${code} failed to render</p>` +
215
+ `${content}</div>`);
216
+ }
217
+ /**
218
+ * Flattens a directive's inner hast children back to plain text, for
219
+ * `:value[name]`'s label (§8: a bare value name). Only top-level TEXT nodes
220
+ * contribute; a nested element (markup the format never asks authors to
221
+ * write inside the label) contributes nothing rather than being partially
222
+ * serialized — mirrors `@markii/react`'s `ValueDirective`'s
223
+ * `extractPlainText`, adapted from a React-children walk to a hast-children
224
+ * walk since this engine never builds a React tree.
225
+ */
226
+ function extractPlainText(children) {
227
+ let text = '';
228
+ for (const child of children) {
229
+ if (child.type === 'text')
230
+ text += child.value;
231
+ }
232
+ return text;
233
+ }
234
+ /** The first element child of `node` named `tagName`, or `undefined`. */
235
+ function findChildElement(node, tagName) {
236
+ for (const child of node.children) {
237
+ if (child.type === 'element' && child.tagName === tagName)
238
+ return child;
239
+ }
240
+ return undefined;
241
+ }
242
+ /** The fence's `language-<lang>` tag from a `<code>` element's class list, or `''`. */
243
+ function getLanguage(codeNode) {
244
+ const className = codeNode?.properties?.className;
245
+ const list = Array.isArray(className) ? className : [];
246
+ for (const name of list) {
247
+ if (typeof name === 'string' && name.startsWith('language-')) {
248
+ return name.slice('language-'.length);
249
+ }
250
+ }
251
+ return '';
252
+ }
253
+ /** The exact fence body text from a `<code>` element's text children, minus the single trailing newline mdast-util-to-hast appends. */
254
+ function getCodeText(codeNode) {
255
+ if (!codeNode)
256
+ return '';
257
+ let text = '';
258
+ for (const child of codeNode.children) {
259
+ if (child.type === 'text')
260
+ text += child.value;
261
+ }
262
+ return text.endsWith('\n') ? text.slice(0, -1) : text;
263
+ }
264
+ /**
265
+ * Folds a script code block (a fence whose meta carries `{name=...}` with a
266
+ * valid script name) into a collapsed `<details>` marker, matching
267
+ * `@markii/react`'s `ScriptMarker` markup. Returns `undefined` for any other
268
+ * `<pre>` so the caller serializes it as an ordinary code block. Never throws.
269
+ */
270
+ function renderScriptMarker(node) {
271
+ try {
272
+ const codeNode = findChildElement(node, 'code');
273
+ const meta = codeNode
274
+ ? (codeNode.properties?.[CODE_META_ATTR] ?? undefined)
275
+ : undefined;
276
+ if (typeof meta !== 'string')
277
+ return undefined;
278
+ const attrs = parseMetaAttributes(meta);
279
+ const name = attrs.name;
280
+ if (!name || !isValidScriptName(name))
281
+ return undefined;
282
+ const src = attrs.src || undefined;
283
+ const detail = src ?? getLanguage(codeNode);
284
+ const summary = detail ? `⚙ ${name} · ${detail}` : `⚙ ${name}`;
285
+ const open = isBareAttribute(meta, OPEN_ATTRIBUTE_KEY);
286
+ const code = getCodeText(codeNode);
287
+ const body = code
288
+ ? `<pre class="mk-script__code"><code>${escapeHtml(code)}</code></pre>`
289
+ : `<p class="mk-script__empty">${src ? `source: ${escapeHtml(src)}` : 'no inline body'}</p>`;
290
+ return (`<details class="mk-script"${open ? ' open' : ''}>` +
291
+ `<summary class="mk-script__summary">${escapeHtml(summary)}</summary>` +
292
+ `${body}</details>`);
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ }
298
+ /** Resolves one directive (registry component, `:value[...]`, or the fallback) given its layout-stripped attributes. Never throws. */
299
+ function renderDirectiveContent(name, kind, attributes, childrenHtml, plainLabel, registry, ctx, scope) {
300
+ if (name === VALUE_DIRECTIVE_NAME)
301
+ return ctx.valueMarker(plainLabel);
302
+ const inline = kind === TEXT_DIRECTIVE_KIND;
303
+ const entry = Object.hasOwn(registry, name) ? registry[name] : undefined;
304
+ const component = readRegistryComponent(entry);
305
+ if (!entry || component == null) {
306
+ return unknownDirective(name || '(unnamed)', inline, childrenHtml, 'unregistered');
307
+ }
308
+ if (isFormMismatch(entry, kind)) {
309
+ return unknownDirective(name || '(unnamed)', inline, childrenHtml, 'form-mismatch');
310
+ }
311
+ const binding = resolveDataAttribute(attributes, scope);
312
+ try {
313
+ return component(binding.attributes, childrenHtml, withDataBinding(ctx, binding));
314
+ }
315
+ catch {
316
+ return componentError(name || '(unnamed)', inline, childrenHtml);
317
+ }
318
+ }
319
+ /** Turns one `<mk-directive>` element (children already transformed) into its HTML string, including the layout wrapper for block directives. */
320
+ function renderDirective(element, registry, ctx, scope) {
321
+ const written = stringProperty(element, 'data-mk-name') ?? '';
322
+ const kind = stringProperty(element, 'data-mk-kind');
323
+ const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
324
+ const childrenHtml = serialize(element.children);
325
+ const plainLabel = written === VALUE_DIRECTIVE_NAME ? extractPlainText(element.children) : '';
326
+ const { name, attributes: aliased } = written === VALUE_DIRECTIVE_NAME
327
+ ? { name: written, attributes: rawAttributes }
328
+ : resolveDirectiveAlias(registry, written, rawAttributes);
329
+ const isBlock = kind !== TEXT_DIRECTIVE_KIND;
330
+ const { attributes, className } = resolveLayoutAttributes(aliased);
331
+ const content = renderDirectiveContent(name, kind, attributes, childrenHtml, plainLabel, registry, ctx, scope);
332
+ return isBlock && className
333
+ ? `<div class="${escapeHtml(className)}">${content}</div>`
334
+ : content;
335
+ }
336
+ /**
337
+ * Post-order tree transform: every `<mk-directive>` and every script `<pre>`
338
+ * is replaced by a `raw` node carrying its finished HTML, and every other
339
+ * element is left in place with its children transformed. A single `serialize`
340
+ * pass afterward emits the whole tree. Directives are processed after their
341
+ * children so a nested directive is already resolved by the time its parent
342
+ * serializes it.
343
+ */
344
+ function makeTransform(registry, ctx, scope) {
345
+ function transform(node) {
346
+ if (node.type !== 'element')
347
+ return node;
348
+ node.children = node.children.map((child) => transform(child));
349
+ if (node.tagName === DIRECTIVE_TAG)
350
+ return raw(renderDirective(node, registry, ctx, scope));
351
+ if (node.tagName === 'pre') {
352
+ const marker = renderScriptMarker(node);
353
+ if (marker !== undefined)
354
+ return raw(marker);
355
+ }
356
+ return node;
357
+ }
358
+ return transform;
359
+ }
360
+ /** The shared "failed to render" fallback box, matching `@markii/react`'s. Never itself throws. */
361
+ function renderFailureFallback(error) {
362
+ const message = error instanceof Error ? error.message : String(error);
363
+ return (`<div class="mk-unknown mk-unknown--block" role="alert">` +
364
+ `<p class="mk-unknown__label">failed to render document</p>` +
365
+ `<pre class="mk-unknown__content">${escapeHtml(message)}</pre></div>`);
366
+ }
367
+ function renderRoot(root, registry, scope) {
368
+ const ctx = createBaseContext(scope);
369
+ const transform = makeTransform(registry, ctx, scope);
370
+ root.children = root.children.map(transform);
371
+ return serialize(root.children);
372
+ }
373
+ /**
374
+ * Renders Markii text to a static HTML string using `registry` to resolve
375
+ * directive names. Pipeline: `@markii/core`'s `toHast` (parse -> tag directive
376
+ * nodes -> remark-rehype -> sanitize URLs) -> a hast->HTML walk that swaps
377
+ * directive elements for registry components (or the unknown-directive
378
+ * fallback) and folds script fences into markers. Pure and never-throwing:
379
+ * parsing is tolerant, unknown names always render a fallback, and any
380
+ * unexpected internal error degrades to the "failed to render document" box.
381
+ *
382
+ * `store` is the note's value store (`@markii/runtime`, §8's pure read path)
383
+ * — optional, matching how a missing/absent value degrades gracefully: with
384
+ * no store, `:value[name]` renders its missing-value marker and every
385
+ * `data=name` attribute resolves to `dataStatus: 'missing'`, but the
386
+ * document still renders completely.
387
+ *
388
+ * `vault` is the optional app-scoped read seam (`@markii/runtime`'s
389
+ * `VaultStore`) that an `@`-prefixed name (`data=@gh.stars`,
390
+ * `:value[@gh.stars]`) resolves against instead of `store` — "bare name =
391
+ * mine, `@name` = the vault's". With no `vault` supplied, every `@name`
392
+ * degrades to `'missing'` the same way an absent `store` degrades a bare
393
+ * name.
394
+ */
395
+ export function renderMarkToHtml(text, registry, store, vault) {
396
+ try {
397
+ return renderRoot(toHast(text), registry, { store, vault });
398
+ }
399
+ catch (error) {
400
+ return renderFailureFallback(error);
401
+ }
402
+ }
403
+ /**
404
+ * The block-level twin of `renderMarkToHtml`: renders one already-parsed mdast
405
+ * node (`@markii/core`'s `MarkNode`) to HTML instead of a whole document's
406
+ * text, via `nodeToHast`. Same registry resolution, same fallbacks, same
407
+ * purity and never-throw guarantees, and the same optional `store`/`vault`
408
+ * value-binding arguments.
409
+ */
410
+ export function renderMarkNodeToHtml(node, registry, store, vault) {
411
+ try {
412
+ return renderRoot(nodeToHast(node), registry, { store, vault });
413
+ }
414
+ catch (error) {
415
+ return renderFailureFallback(error);
416
+ }
417
+ }
@@ -0,0 +1,59 @@
1
+ import type { FailureKind, ValueStatus, ValueStore, VaultStore } from '@markii/runtime';
2
+ /**
3
+ * The string engine's port of `@markii/react`'s `store-path.ts`: resolves a
4
+ * `data=`/`:value[]` name (optionally dotted, optionally `@`-prefixed for the
5
+ * vault) against a `ValueStore`/`VaultStore`. Ported rather than imported
6
+ * because `store-path.ts` is not part of `@markii/react`'s public export
7
+ * surface, and the two platform renderers are deliberately independent
8
+ * implementations of the same contract (docs/scripting.md), not a shared
9
+ * runtime dependency on each other. The resolution semantics — dotted-path
10
+ * walk, `@`-scoping, never-throw-on-a-hostile-store — are kept identical to
11
+ * the React port; only the presentation layer built on top differs by engine.
12
+ */
13
+ /** What resolving a (possibly dotted) name against a `ValueStore`/`VaultStore` produces. */
14
+ export interface StorePathResolution {
15
+ value: unknown;
16
+ status: ValueStatus;
17
+ /** The root entry's error message, if it has one — carried through regardless of how the rest of the path resolved. Always a `string` when present. */
18
+ error?: string;
19
+ /** The root entry's `failureKind`, carried through exactly like `error`. Absent when the root entry didn't carry one, or on a host-store fault (never invented). */
20
+ failureKind?: FailureKind;
21
+ }
22
+ /**
23
+ * Best-effort description of something host code threw, for the tooltip
24
+ * channel. Every step is itself guarded: `instanceof`, `.message`, and
25
+ * `String(...)` can all throw when the thrown value is a revoked `Proxy` or
26
+ * an object with hostile traps/getters.
27
+ */
28
+ export declare function describeHostFault(err: unknown): string;
29
+ /** The one-character prefix that routes a `data=`/`:value[]` name at the vault store instead of the note store (docs/scripting.md). */
30
+ export declare const VAULT_NAME_PREFIX = "@";
31
+ /** Where a `resolveScopedPath` lookup may read from. Either half may be absent. */
32
+ export interface ValueScope {
33
+ store?: ValueStore;
34
+ vault?: VaultStore;
35
+ }
36
+ /**
37
+ * Resolves a `data=`/`:value[]` name against `store`, walking a dotted path.
38
+ * Never throws, including against a hostile or buggy host store.
39
+ */
40
+ export declare function resolveStorePath(store: ValueStore | undefined, dottedName: string): StorePathResolution;
41
+ /**
42
+ * Resolves a `data=`/`:value[]` name against a `ValueScope`, routing an
43
+ * `@`-prefixed name at `scope.vault` instead of `scope.store` ("bare name =
44
+ * mine, `@name` = the vault's"). Never throws.
45
+ */
46
+ export declare function resolveScopedPath(scope: ValueScope, dottedName: string): StorePathResolution;
47
+ /** What `safeRead` produces: the extracted fields, plus the thrown message when the extraction had to be abandoned. */
48
+ export interface SafeRead<T> {
49
+ fields: T;
50
+ fault?: string;
51
+ }
52
+ /**
53
+ * Runs `read` — an extraction that touches an untrusted bound `data` value —
54
+ * and falls back to `fallback()` if any part of it throws. Ported from
55
+ * `@markii/react`'s `safe-data.ts`: wraps the WHOLE extraction, not each
56
+ * individual property read, so a value whose reads throw degrades to the
57
+ * same "nothing resolved" state a genuinely missing binding already renders.
58
+ */
59
+ export declare function safeRead<T>(read: () => T, fallback: () => T): SafeRead<T>;
@@ -0,0 +1,154 @@
1
+ const MISSING = { value: undefined, status: 'missing' };
2
+ /** `ValueStatus`'s members, for validating a status read back off an untrusted entry. */
3
+ const VALUE_STATUSES = [
4
+ 'fresh',
5
+ 'stale',
6
+ 'error',
7
+ 'missing',
8
+ ];
9
+ function isValueStatus(value) {
10
+ return (typeof value === 'string' &&
11
+ VALUE_STATUSES.includes(value));
12
+ }
13
+ /**
14
+ * The message a host-data fault falls back to when even describing the
15
+ * thrown value throws. Deliberately generic: it only ever reaches a tooltip.
16
+ */
17
+ const HOST_FAULT_MESSAGE = 'value store threw while reading this name';
18
+ /**
19
+ * Best-effort description of something host code threw, for the tooltip
20
+ * channel. Every step is itself guarded: `instanceof`, `.message`, and
21
+ * `String(...)` can all throw when the thrown value is a revoked `Proxy` or
22
+ * an object with hostile traps/getters.
23
+ */
24
+ export function describeHostFault(err) {
25
+ try {
26
+ if (err instanceof Error) {
27
+ const { message } = err;
28
+ if (typeof message === 'string' && message !== '')
29
+ return message;
30
+ }
31
+ const text = String(err);
32
+ return text === '' ? HOST_FAULT_MESSAGE : text;
33
+ }
34
+ catch {
35
+ return HOST_FAULT_MESSAGE;
36
+ }
37
+ }
38
+ /** The degraded resolution for a HOST-STORE FAULT: resolves to `'missing'`, carrying the thrown message as `error`, never inventing a `failureKind`. */
39
+ function hostFault(err) {
40
+ return { value: undefined, status: 'missing', error: describeHostFault(err) };
41
+ }
42
+ /** The one-character prefix that routes a `data=`/`:value[]` name at the vault store instead of the note store (docs/scripting.md). */
43
+ export const VAULT_NAME_PREFIX = '@';
44
+ /** Reads the four `StoredValue` fields off an entry a host store handed back. MAY THROW; every caller wraps it. */
45
+ function readEntry(entry) {
46
+ const value = entry.value;
47
+ const status = entry.status;
48
+ const error = entry.error;
49
+ const failureKind = entry.failureKind;
50
+ return {
51
+ value,
52
+ status: isValueStatus(status) ? status : 'missing',
53
+ error: typeof error === 'string' ? error : undefined,
54
+ failureKind: typeof failureKind === 'string'
55
+ ? failureKind
56
+ : undefined,
57
+ };
58
+ }
59
+ /** The partial-path degradation: `missing`, but still carrying the root entry's own error/kind. */
60
+ function partialMiss(entry) {
61
+ return {
62
+ value: undefined,
63
+ status: 'missing',
64
+ error: entry.error,
65
+ failureKind: entry.failureKind,
66
+ };
67
+ }
68
+ /**
69
+ * Walks `segments[1:]` into `entry.value`. Never throws: a hostile stored
70
+ * value (a revoked `Proxy`, throwing traps) degrades to `hostFault` rather
71
+ * than escaping. `Object.hasOwn` is the load-bearing guard against a segment
72
+ * like `__proto__`/`constructor` resolving through the prototype chain.
73
+ */
74
+ function walkSegments(entry, segments) {
75
+ let current = entry.value;
76
+ for (let index = 1; index < segments.length; index += 1) {
77
+ const segment = segments[index];
78
+ if (!segment || current === null || typeof current !== 'object') {
79
+ return partialMiss(entry);
80
+ }
81
+ let next;
82
+ try {
83
+ if (!Object.hasOwn(current, segment))
84
+ return partialMiss(entry);
85
+ next = current[segment];
86
+ }
87
+ catch (err) {
88
+ return hostFault(err);
89
+ }
90
+ current = next;
91
+ }
92
+ return {
93
+ value: current,
94
+ status: entry.status,
95
+ error: entry.error,
96
+ failureKind: entry.failureKind,
97
+ };
98
+ }
99
+ /** The shared never-throw shell around a store lookup. */
100
+ function resolveEntryPath(lookup, segments) {
101
+ let entry;
102
+ try {
103
+ const raw = lookup();
104
+ if (!raw)
105
+ return MISSING;
106
+ entry = readEntry(raw);
107
+ }
108
+ catch (err) {
109
+ return hostFault(err);
110
+ }
111
+ return walkSegments(entry, segments);
112
+ }
113
+ /**
114
+ * Resolves a `data=`/`:value[]` name against `store`, walking a dotted path.
115
+ * Never throws, including against a hostile or buggy host store.
116
+ */
117
+ export function resolveStorePath(store, dottedName) {
118
+ const segments = dottedName.split('.');
119
+ const root = segments[0];
120
+ if (!root)
121
+ return MISSING;
122
+ return resolveEntryPath(() => store?.get(root), segments);
123
+ }
124
+ /**
125
+ * Resolves a `data=`/`:value[]` name against a `ValueScope`, routing an
126
+ * `@`-prefixed name at `scope.vault` instead of `scope.store` ("bare name =
127
+ * mine, `@name` = the vault's"). Never throws.
128
+ */
129
+ export function resolveScopedPath(scope, dottedName) {
130
+ if (dottedName.startsWith(VAULT_NAME_PREFIX)) {
131
+ const remainder = dottedName.slice(VAULT_NAME_PREFIX.length);
132
+ const segments = remainder.split('.');
133
+ const root = segments[0];
134
+ if (!root)
135
+ return MISSING;
136
+ return resolveEntryPath(() => scope.vault?.get(root), segments);
137
+ }
138
+ return resolveStorePath(scope.store, dottedName);
139
+ }
140
+ /**
141
+ * Runs `read` — an extraction that touches an untrusted bound `data` value —
142
+ * and falls back to `fallback()` if any part of it throws. Ported from
143
+ * `@markii/react`'s `safe-data.ts`: wraps the WHOLE extraction, not each
144
+ * individual property read, so a value whose reads throw degrades to the
145
+ * same "nothing resolved" state a genuinely missing binding already renders.
146
+ */
147
+ export function safeRead(read, fallback) {
148
+ try {
149
+ return { fields: read() };
150
+ }
151
+ catch (err) {
152
+ return { fields: fallback(), fault: describeHostFault(err) };
153
+ }
154
+ }
@@ -0,0 +1,9 @@
1
+ import type { HtmlRenderContext } from '../registry.js';
2
+ /**
3
+ * A minimal, unbound `HtmlRenderContext` for component unit tests that don't
4
+ * exercise value binding: `resolve` always misses, `valueMarker` renders the
5
+ * ordinary missing-value span. Colocated component tests (`callout.test.ts`
6
+ * and siblings) use this instead of hand-building a context object, so
7
+ * `HtmlRenderContext`'s shape can grow without touching every test file.
8
+ */
9
+ export declare function createTestContext(overrides?: Partial<HtmlRenderContext>): HtmlRenderContext;
@@ -0,0 +1,21 @@
1
+ import { escapeHtml } from '../escape.js';
2
+ /**
3
+ * A minimal, unbound `HtmlRenderContext` for component unit tests that don't
4
+ * exercise value binding: `resolve` always misses, `valueMarker` renders the
5
+ * ordinary missing-value span. Colocated component tests (`callout.test.ts`
6
+ * and siblings) use this instead of hand-building a context object, so
7
+ * `HtmlRenderContext`'s shape can grow without touching every test file.
8
+ */
9
+ export function createTestContext(overrides = {}) {
10
+ return {
11
+ esc: escapeHtml,
12
+ resolve() {
13
+ return { value: undefined, status: 'missing' };
14
+ },
15
+ valueMarker(name) {
16
+ const label = name.trim() ? name.trim() : 'value';
17
+ return `<span class="mk-value mk-value--missing">{${escapeHtml(label)}}</span>`;
18
+ },
19
+ ...overrides,
20
+ };
21
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Ported from `@markii/react`'s `components/value-directive.tsx`: coerces a
3
+ * resolved store value to display text for `:value[...]`. Kept as its own
4
+ * tiny module because it has no dependency on hast/registry types and is
5
+ * useful in isolation (colocated test).
6
+ */
7
+ /**
8
+ * Coerces a stored value to display text. Objects/arrays render as JSON;
9
+ * `null`/`undefined` render as an empty string. Never throws for any stored
10
+ * value.
11
+ */
12
+ export declare function stringifyStoredValue(value: unknown): string;