@markii/ansi 0.0.1

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 (73) hide show
  1. package/dist/ansi.d.ts +79 -0
  2. package/dist/ansi.js +136 -0
  3. package/dist/box.d.ts +47 -0
  4. package/dist/box.js +209 -0
  5. package/dist/components/badge.d.ts +10 -0
  6. package/dist/components/badge.js +44 -0
  7. package/dist/components/callout.d.ts +20 -0
  8. package/dist/components/callout.js +71 -0
  9. package/dist/components/card.d.ts +12 -0
  10. package/dist/components/card.js +44 -0
  11. package/dist/components/cell.d.ts +18 -0
  12. package/dist/components/cell.js +31 -0
  13. package/dist/components/chart.d.ts +15 -0
  14. package/dist/components/chart.js +129 -0
  15. package/dist/components/details.d.ts +12 -0
  16. package/dist/components/details.js +25 -0
  17. package/dist/components/divider.d.ts +4 -0
  18. package/dist/components/divider.js +80 -0
  19. package/dist/components/figure.d.ts +20 -0
  20. package/dist/components/figure.js +53 -0
  21. package/dist/components/index.d.ts +34 -0
  22. package/dist/components/index.js +109 -0
  23. package/dist/components/kbd.d.ts +7 -0
  24. package/dist/components/kbd.js +8 -0
  25. package/dist/components/layout-wrapper.d.ts +35 -0
  26. package/dist/components/layout-wrapper.js +62 -0
  27. package/dist/components/progress.d.ts +15 -0
  28. package/dist/components/progress.js +78 -0
  29. package/dist/components/rating.d.ts +9 -0
  30. package/dist/components/rating.js +28 -0
  31. package/dist/components/row.d.ts +26 -0
  32. package/dist/components/row.js +67 -0
  33. package/dist/components/stat.d.ts +13 -0
  34. package/dist/components/stat.js +79 -0
  35. package/dist/components/tab.d.ts +13 -0
  36. package/dist/components/tab.js +17 -0
  37. package/dist/components/table-grid.d.ts +34 -0
  38. package/dist/components/table-grid.js +124 -0
  39. package/dist/components/table.d.ts +16 -0
  40. package/dist/components/table.js +101 -0
  41. package/dist/components/tabs.d.ts +18 -0
  42. package/dist/components/tabs.js +30 -0
  43. package/dist/failure-presentation.d.ts +58 -0
  44. package/dist/failure-presentation.js +124 -0
  45. package/dist/href-resolve.d.ts +21 -0
  46. package/dist/href-resolve.js +25 -0
  47. package/dist/image-resolve.d.ts +41 -0
  48. package/dist/image-resolve.js +28 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +17 -0
  51. package/dist/layout.d.ts +73 -0
  52. package/dist/layout.js +144 -0
  53. package/dist/measure.d.ts +25 -0
  54. package/dist/measure.js +108 -0
  55. package/dist/registry.d.ts +227 -0
  56. package/dist/registry.js +121 -0
  57. package/dist/render.d.ts +69 -0
  58. package/dist/render.js +889 -0
  59. package/dist/resolve.d.ts +60 -0
  60. package/dist/resolve.js +152 -0
  61. package/dist/sanitize.d.ts +51 -0
  62. package/dist/sanitize.js +101 -0
  63. package/dist/style.d.ts +9 -0
  64. package/dist/style.js +13 -0
  65. package/dist/theme.d.ts +36 -0
  66. package/dist/theme.js +79 -0
  67. package/dist/url-resolve.d.ts +54 -0
  68. package/dist/url-resolve.js +82 -0
  69. package/dist/value-format.d.ts +13 -0
  70. package/dist/value-format.js +16 -0
  71. package/dist/value-types.d.ts +37 -0
  72. package/dist/value-types.js +17 -0
  73. package/package.json +61 -0
package/dist/render.js ADDED
@@ -0,0 +1,889 @@
1
+ import { parse, toHast, nodeToHast, isSafeUrl, parseMetaAttributes, isValidScriptName, } from '@markii/core';
2
+ import { bold, dim, hyperlink, inverse, italic, resolveColorOption, underline, } from './ansi.js';
3
+ import { style } from './style.js';
4
+ import { defaultAnsiTheme } from './theme.js';
5
+ import { columns, frame, pad, rule, wrap } from './box.js';
6
+ import { measure } from './measure.js';
7
+ import { sanitizeBlockText, sanitizeUrlText, stripControlCharacters, } from './sanitize.js';
8
+ import { readRegistryComponent, registryLayoutAxis, registrySelfLayout, resolveDirectiveAlias, } from './registry.js';
9
+ import { applyLayout, resolveLayoutAttributes, } from './layout.js';
10
+ import { resolveScopedPath } from './resolve.js';
11
+ import { dataStateSuffix, emptyInlineTitle, failureToken, invalidAttributeValueLabel, invalidAttributeValueTitle, } from './failure-presentation.js';
12
+ import { formatValue, getContract, reportDiagnostic, } from '@markii/stdlib';
13
+ import { resolveImageAttribute, } from './image-resolve.js';
14
+ import { resolveHrefAttribute } from './href-resolve.js';
15
+ import { defaultAnsiRegistry } from './components/index.js';
16
+ import { drawTableGrid } from './components/table-grid.js';
17
+ /**
18
+ * The hast walk for the terminal engine, mirroring `@markii/html`'s
19
+ * `render.ts` structure closely enough that a reader of one can read the
20
+ * other: the same directive-resolution pipeline (alias, layout, data
21
+ * binding, form mismatch, invalid-enum notice, empty-inline notice,
22
+ * never-throw component containment), applied to a WIDTH-AWARE plain-text
23
+ * output instead of an HTML string.
24
+ *
25
+ * The one structural difference `@markii/html` does not have: wrapping is
26
+ * not free here. A browser reflows HTML at whatever width the viewport
27
+ * gives it; a terminal line is fixed the moment this engine emits it. Every
28
+ * block-rendering function below therefore takes an explicit `width`
29
+ * (columns available) and, for a directive, an `indent` (the prefix an
30
+ * enclosing block has already applied), and produces its OWN wrapped lines
31
+ * rather than leaving wrapping to something downstream.
32
+ *
33
+ * Interactive components have no meaning in a terminal: `@markii/stdlib`'s
34
+ * `INTERACTIVE_ATTRIBUTE` marks an element a host should treat as a live
35
+ * control (a script-marker `<summary>`, say); this engine has no notion of
36
+ * a live control at all, so it never reads that attribute and never emits
37
+ * a marker for it — there is nothing to degrade, unlike a genuine failure.
38
+ */
39
+ /** The hast tag name `@markii/core` marks every directive with. */
40
+ const DIRECTIVE_TAG = 'mk-directive';
41
+ /** `data-mk-kind` value for a TEXT (inline) directive; the other two kinds are block. */
42
+ const TEXT_DIRECTIVE_KIND = 'textDirective';
43
+ /** The reserved built-in for render-time value interpolation (docs/spec.md §8). */
44
+ const VALUE_DIRECTIVE_NAME = 'value';
45
+ /** The one attribute key that binds a directive to the value store. */
46
+ const DATA_ATTRIBUTE_KEY = 'data';
47
+ /** The attribute `@markii/core` preserves a code fence's raw `meta` string onto. */
48
+ const CODE_META_ATTR = 'data-mk-meta';
49
+ /** Block-level hast tags this engine recognizes; anything else inside a directive's children is treated as inline content. */
50
+ const BLOCK_TAGS = new Set([
51
+ 'p',
52
+ 'ul',
53
+ 'ol',
54
+ 'blockquote',
55
+ 'pre',
56
+ 'hr',
57
+ 'table',
58
+ 'h1',
59
+ 'h2',
60
+ 'h3',
61
+ 'h4',
62
+ 'h5',
63
+ 'h6',
64
+ ]);
65
+ function isMarkRoot(node) {
66
+ return node.type === 'root';
67
+ }
68
+ function nodeOrRootToHast(node) {
69
+ if (!isMarkRoot(node))
70
+ return nodeToHast(node);
71
+ const children = [];
72
+ for (const child of node.children) {
73
+ children.push(...nodeToHast(child).children);
74
+ }
75
+ return { type: 'root', children };
76
+ }
77
+ /** A directive property read back as a string, or `undefined` if absent or non-string. */
78
+ function stringProperty(element, name) {
79
+ const value = element.properties?.[name];
80
+ return typeof value === 'string' ? value : undefined;
81
+ }
82
+ /** Parses the `data-mk-attrs` JSON back into an attribute map, keeping only string/null values. Never throws. */
83
+ function parseAttributes(json) {
84
+ if (!json)
85
+ return {};
86
+ try {
87
+ const parsed = JSON.parse(json);
88
+ if (parsed === null ||
89
+ typeof parsed !== 'object' ||
90
+ Array.isArray(parsed)) {
91
+ return {};
92
+ }
93
+ const result = {};
94
+ for (const [key, value] of Object.entries(parsed)) {
95
+ if (typeof value === 'string' || value === null || value === undefined) {
96
+ result[key] = value;
97
+ }
98
+ }
99
+ return result;
100
+ }
101
+ catch {
102
+ return {};
103
+ }
104
+ }
105
+ /** Flattens a directive's inner hast children back to plain text, for `:value[name]`'s label (only top-level TEXT nodes contribute). */
106
+ function extractPlainText(children) {
107
+ let text = '';
108
+ for (const child of children) {
109
+ if (child.type === 'text')
110
+ text += child.value;
111
+ }
112
+ return text;
113
+ }
114
+ /** Whether `entry` is a BLOCK component written inline. Fails permissive on a throwing `inline` getter. */
115
+ function isFormMismatch(entry, kind) {
116
+ if (kind !== TEXT_DIRECTIVE_KIND)
117
+ return false;
118
+ try {
119
+ return entry.inline === false;
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ /** Whether `entry` is registered `inline: true`, read the same hostile-configuration-safe way. */
126
+ function isRegisteredInline(entry) {
127
+ try {
128
+ return entry.inline === true;
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ /** The fallback label line, worded for the reason and the form the directive was written in. Plain text (no escapes). */
135
+ function fallbackLabel(name, inline, reason) {
136
+ const code = `\`${stripControlCharacters(name)}\``;
137
+ if (reason === 'form-mismatch') {
138
+ return inline
139
+ ? `block component ${code} written inline`
140
+ : `inline component ${code} written as a block`;
141
+ }
142
+ return `unknown component ${code}`;
143
+ }
144
+ /**
145
+ * Renders `children`'s default (whole-body) text, never throwing. Used only
146
+ * on already-exceptional paths (an unregistered directive, a form mismatch,
147
+ * a component that itself threw): the render walk underneath `children` is
148
+ * documented never-throw, but these three call sites are exactly the ones
149
+ * built to contain a failure, so they must not become a new way to produce
150
+ * one themselves.
151
+ */
152
+ function safeChildrenText(children) {
153
+ try {
154
+ return children();
155
+ }
156
+ catch {
157
+ return '';
158
+ }
159
+ }
160
+ /** The unknown-directive fallback: dim inline text for a text directive, a dashed dim frame for a block directive. Wording matches `@markii/html`'s `fallbackLabel` exactly. */
161
+ function unknownDirective(name, inline, childrenText, reason, width, color) {
162
+ const label = fallbackLabel(name, inline, reason);
163
+ // The label and the passed-through content need a visible boundary. The
164
+ // HTML and React engines get one from the fallback's own styling, which a
165
+ // terminal cannot rely on: with color off there is nothing at all between
166
+ // the two, so the label runs straight into the author's words. A colon is
167
+ // the separator, and only when there is content to separate.
168
+ if (inline) {
169
+ return childrenText === ''
170
+ ? dim(label, color)
171
+ : `${dim(`${label}:`, color)} ${childrenText}`;
172
+ }
173
+ const innerWidth = Math.max(1, width - 4);
174
+ const wrappedChildren = childrenText
175
+ ? childrenText
176
+ .split('\n')
177
+ .flatMap((line) => wrap(line, innerWidth))
178
+ .join('\n')
179
+ : '';
180
+ const body = wrappedChildren ? `${label}\n\n${wrappedChildren}` : label;
181
+ return dim(frame(body, { style: 'dashed', width }), color);
182
+ }
183
+ /** A registered component that threw while rendering, contained to a neutral box rather than failing the whole document. Never dumps the error text. */
184
+ function componentError(name, inline, childrenText, width, color) {
185
+ const label = `component \`${stripControlCharacters(name)}\` failed to render`;
186
+ // Same boundary rule as `unknownDirective`'s inline branch above.
187
+ if (inline) {
188
+ return childrenText === ''
189
+ ? dim(label, color)
190
+ : `${dim(`${label}:`, color)} ${childrenText}`;
191
+ }
192
+ return dim(frame(label, { style: 'dashed', width }), color);
193
+ }
194
+ /** Splits a `data=<name>` attribute off `attributes` and resolves it against `scope`. Never throws. Mirrors `@markii/html`'s `resolveDataAttribute`. */
195
+ function resolveDataAttribute(attributes, scope) {
196
+ if (!Object.hasOwn(attributes, DATA_ATTRIBUTE_KEY))
197
+ return { attributes };
198
+ const { [DATA_ATTRIBUTE_KEY]: rawName, ...rest } = attributes;
199
+ if (!rawName)
200
+ return { attributes: rest, data: undefined, dataStatus: 'missing' };
201
+ const resolved = resolveScopedPath(scope, rawName);
202
+ return {
203
+ attributes: rest,
204
+ data: resolved.value,
205
+ dataStatus: resolved.status,
206
+ dataError: resolved.error,
207
+ dataFailureKind: resolved.status === 'error' ? resolved.failureKind : undefined,
208
+ };
209
+ }
210
+ /** Whether one of `name`'s `@markii/stdlib` contract attributes with a closed `enum` is present with a value outside it. Mirrors `@markii/html`'s identical check. */
211
+ function invalidEnumAttribute(name, attributes) {
212
+ const contract = getContract(name);
213
+ if (!contract)
214
+ return undefined;
215
+ for (const [attribute, schema] of Object.entries(contract.attributes)) {
216
+ const enumValues = schema.enum;
217
+ if (!enumValues)
218
+ continue;
219
+ const value = attributes[attribute];
220
+ if (value === undefined || value === null || value === '')
221
+ continue;
222
+ if (!enumValues.includes(value))
223
+ return { attribute, value };
224
+ }
225
+ return undefined;
226
+ }
227
+ /** Resolves a `data=`/`:value[]` name against `scope`. Never throws. */
228
+ function resolveValue(scope, name) {
229
+ const trimmed = name.trim();
230
+ if (!trimmed)
231
+ return { value: undefined, status: 'missing' };
232
+ return resolveScopedPath(scope, trimmed);
233
+ }
234
+ /**
235
+ * Builds the missing/stale/resolved marker text for a resolved value name.
236
+ * A plain miss (no error attached) shows only the `{name}` placeholder —
237
+ * there is nothing more to say. An `'error'` status, or a stale value, gets
238
+ * `failure-presentation.ts`'s `dataStateSuffix` appended as visible trailing
239
+ * text: a terminal has no tooltip channel, so the reason has to reach the
240
+ * text itself (AGENTS.md's "clean is not silent").
241
+ */
242
+ function renderValueMarker(name, resolved, format, decimals, color, theme) {
243
+ if (resolved.status === 'missing' || resolved.status === 'error') {
244
+ const failureKind = resolved.status === 'error' ? resolved.failureKind : undefined;
245
+ const label = name ? `{${stripControlCharacters(name)}}` : '{value}';
246
+ const token = failureToken(failureKind);
247
+ const styled = token
248
+ ? style(label, token, theme, color)
249
+ : dim(label, color);
250
+ const suffix = resolved.status === 'error' ? dataStateSuffix('error', failureKind) : '';
251
+ return suffix ? `${styled}${dim(suffix, color)}` : styled;
252
+ }
253
+ const text = stripControlCharacters(formatValue(resolved.value, format, decimals));
254
+ const styled = resolved.status === 'stale' ? underline(text, color) : text;
255
+ const suffix = resolved.status === 'stale' ? dataStateSuffix('stale', undefined) : '';
256
+ return suffix ? `${styled}${dim(suffix, color)}` : styled;
257
+ }
258
+ /** Builds the `AnsiRenderContext` handed to one component invocation. */
259
+ function buildComponentContext(width, indent, wctx, binding, layout) {
260
+ const { color, theme, scope } = wctx;
261
+ const ctx = {
262
+ width,
263
+ indent,
264
+ color,
265
+ theme,
266
+ style: (text, token) => style(text, token, theme, color),
267
+ bold: (text) => bold(text, color),
268
+ dim: (text) => dim(text, color),
269
+ italic: (text) => italic(text, color),
270
+ underline: (text) => underline(text, color),
271
+ inverse: (text) => inverse(text, color),
272
+ wrap: (text, w) => wrap(text, w),
273
+ pad: (text, w, align) => pad(text, w, align),
274
+ columns: (blocks, widths, gutter) => columns(blocks, widths, gutter),
275
+ frame: (block, options) => frame(block, options),
276
+ rule: (w, char) => rule(w, char),
277
+ text: (value) => stripControlCharacters(value),
278
+ resolve: (name) => resolveValue(scope, name),
279
+ valueMarker: (name, format, decimals) => {
280
+ const trimmed = name.trim();
281
+ const resolved = trimmed
282
+ ? resolveScopedPath(scope, trimmed)
283
+ : { value: undefined, status: 'missing' };
284
+ return renderValueMarker(trimmed, resolved, format, decimals, color, theme);
285
+ },
286
+ resolveImageSrc: wctx.resolveImageSrc,
287
+ resolveHref: wctx.resolveHref,
288
+ onDiagnostic: wctx.onDiagnostic,
289
+ };
290
+ if ('data' in binding) {
291
+ ctx.data = binding.data;
292
+ ctx.dataStatus = binding.dataStatus;
293
+ ctx.dataError = binding.dataError;
294
+ ctx.dataFailureKind = binding.dataFailureKind;
295
+ }
296
+ if (layout)
297
+ ctx.layout = layout;
298
+ return ctx;
299
+ }
300
+ /** Resolves one directive (registry component, `:value[...]`, or a fallback), given its layout-stripped attributes. Never throws. */
301
+ function renderDirectiveContent(name, kind, isBlock, attributes, children, width, indent, wctx, layoutForComponent) {
302
+ const inline = !isBlock;
303
+ const entry = Object.hasOwn(wctx.registry, name)
304
+ ? wctx.registry[name]
305
+ : undefined;
306
+ const component = readRegistryComponent(entry);
307
+ if (!entry || component == null) {
308
+ return unknownDirective(name || '(unnamed)', inline, safeChildrenText(children), 'unregistered', width, wctx.color);
309
+ }
310
+ if (isFormMismatch(entry, kind)) {
311
+ return unknownDirective(name || '(unnamed)', inline, safeChildrenText(children), 'form-mismatch', width, wctx.color);
312
+ }
313
+ const binding = resolveDataAttribute(attributes, wctx.scope);
314
+ const ctx = buildComponentContext(width, indent, wctx, binding, layoutForComponent);
315
+ let rendered;
316
+ try {
317
+ rendered = component(binding.attributes, children, ctx);
318
+ }
319
+ catch {
320
+ return componentError(name || '(unnamed)', inline, safeChildrenText(children), width, wctx.color);
321
+ }
322
+ // The silent-value-drop mechanism (AGENTS.md "clean is not silent"): a
323
+ // known attribute's value outside its closed enum still renders the
324
+ // component exactly as registered, with a short labeled marker appended
325
+ // (there is no tooltip channel in a terminal, so the full reason cannot
326
+ // live inline without becoming body text again). The full sentence is
327
+ // reported to `onDiagnostic` for a host's own diagnostics surface; a
328
+ // caller that passes no `onDiagnostic` simply gets the marker with no
329
+ // reason anywhere, exactly like the browser engines with no tooltip read.
330
+ //
331
+ // A block component's marker goes on its OWN line. Appended to the same
332
+ // line it would land against the bottom edge of whatever the component
333
+ // drew, turning a clean box into a box with a label stuck to its corner.
334
+ // Only an inline component, which is already part of a line of text,
335
+ // takes the marker inline.
336
+ const markerSeparator = inline ? ' ' : '\n';
337
+ const invalidEnum = invalidEnumAttribute(name, binding.attributes);
338
+ if (invalidEnum) {
339
+ reportDiagnostic(wctx.onDiagnostic, {
340
+ kind: 'invalid-attribute-value',
341
+ directive: name,
342
+ attribute: invalidEnum.attribute,
343
+ message: invalidAttributeValueTitle(name, invalidEnum.attribute, invalidEnum.value),
344
+ });
345
+ const label = invalidAttributeValueLabel(name, invalidEnum.attribute);
346
+ rendered = `${rendered}${markerSeparator}${dim(`[${label}]`, wctx.color)}`;
347
+ }
348
+ // An `inline: true` component with no content still renders exactly as
349
+ // registered, with the same quiet trailing marker.
350
+ if (isRegisteredInline(entry) && safeChildrenText(children).trim() === '') {
351
+ rendered = `${rendered}${dim(` (${emptyInlineTitle(name)})`, wctx.color)}`;
352
+ }
353
+ return rendered;
354
+ }
355
+ /** Whether `children` reads as block-level content (a container directive's body) rather than inline/phrasing content (a leaf directive's body). */
356
+ function isBlockLevelChildren(children) {
357
+ for (const child of children) {
358
+ if (child.type !== 'element')
359
+ continue;
360
+ if (BLOCK_TAGS.has(child.tagName))
361
+ return true;
362
+ if (child.tagName === DIRECTIVE_TAG) {
363
+ const kind = stringProperty(child, 'data-mk-kind');
364
+ if (kind !== TEXT_DIRECTIVE_KIND)
365
+ return true;
366
+ }
367
+ }
368
+ return false;
369
+ }
370
+ function renderDirectiveChildren(children, width, indent, wctx) {
371
+ if (isBlockLevelChildren(children)) {
372
+ return renderBlocks(children, width, indent, wctx);
373
+ }
374
+ return renderInlineChildren(children, width, indent, wctx);
375
+ }
376
+ /** The resolved directive name for a `<mk-directive>` element, after alias resolution — the name `AnsiChildPart.name` reports. Attributes play no part in identifying a child this way, so an empty attribute map is enough. */
377
+ function resolvedChildDirectiveName(element, registry) {
378
+ const written = stringProperty(element, 'data-mk-name') ?? '';
379
+ return resolveDirectiveAlias(registry, written, {}).name;
380
+ }
381
+ /**
382
+ * Builds one `AnsiChildPart` per top-level child of a directive's BLOCK
383
+ * body (a pure whitespace-only text node between blocks, the same kind
384
+ * `renderBlocks` already treats as carrying no content, contributes no
385
+ * part). Each part renders lazily via the same per-node `renderBlock` the
386
+ * ordinary block walk uses, so a container that calls `part.render` at its
387
+ * own chosen width gets exactly what a document-level render at that width
388
+ * would have produced — no separate rendering path to drift from the
389
+ * ordinary one.
390
+ */
391
+ function buildBlockChildParts(nodes, width, indent, wctx) {
392
+ const parts = [];
393
+ for (const node of nodes) {
394
+ if (node.type === 'text' && node.value.trim() === '')
395
+ continue;
396
+ const name = node.type === 'element' && node.tagName === DIRECTIVE_TAG
397
+ ? resolvedChildDirectiveName(node, wctx.registry)
398
+ : undefined;
399
+ parts.push({
400
+ name,
401
+ render: (options) => renderBlock(node, options?.width ?? width, options?.indent ?? indent, wctx),
402
+ });
403
+ }
404
+ return parts;
405
+ }
406
+ /**
407
+ * Builds the `AnsiChildren` handle a component receives (`registry.ts`'s
408
+ * doc comment on the type): calling it renders the WHOLE body, lazily, at
409
+ * an optionally narrower width/indent; `.parts` exposes each top-level
410
+ * child separately for a container (`row`) that must size each one before
411
+ * it is drawn. Nothing under `element` is rendered until a caller actually
412
+ * invokes one of these functions.
413
+ */
414
+ function buildChildren(element, width, indent, wctx) {
415
+ const isBlock = isBlockLevelChildren(element.children);
416
+ const fn = ((options) => renderDirectiveChildren(element.children, options?.width ?? width, options?.indent ?? indent, wctx));
417
+ Object.defineProperty(fn, 'parts', {
418
+ enumerable: true,
419
+ get: () => isBlock
420
+ ? buildBlockChildParts(element.children, width, indent, wctx)
421
+ : [
422
+ {
423
+ render: (options) => renderInlineChildren(element.children, options?.width ?? width, options?.indent ?? indent, wctx),
424
+ },
425
+ ],
426
+ });
427
+ return fn;
428
+ }
429
+ /** Turns one `<mk-directive>` element into its rendered text, including the layout adjustment for a block directive whose layout it does not own. */
430
+ function renderDirective(element, width, indent, wctx) {
431
+ const written = stringProperty(element, 'data-mk-name') ?? '';
432
+ const kind = stringProperty(element, 'data-mk-kind');
433
+ const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
434
+ const isBlock = kind !== TEXT_DIRECTIVE_KIND;
435
+ if (written === VALUE_DIRECTIVE_NAME) {
436
+ const plainLabel = extractPlainText(element.children);
437
+ const resolved = resolveValue(wctx.scope, plainLabel);
438
+ return renderValueMarker(plainLabel.trim(), resolved, rawAttributes.format ?? undefined, rawAttributes.decimals ?? undefined, wctx.color, wctx.theme);
439
+ }
440
+ const { name, attributes: aliased } = resolveDirectiveAlias(wctx.registry, written, rawAttributes);
441
+ const ownedAxis = registryLayoutAxis(wctx.registry, name);
442
+ const { attributes, resolved: layoutResolved } = resolveLayoutAttributes(aliased, isBlock ? ownedAxis : undefined);
443
+ const isLayoutScope = ownedAxis !== undefined && isBlock;
444
+ // A `selfLayout` component (a box-drawing standard component: card,
445
+ // callout, table, chart) also owns applying its own width/align — see
446
+ // `registry.ts`'s `AnsiRegistryEntry.selfLayout` doc comment for why the
447
+ // generic post-render `applyLayout` below would corrupt its frame.
448
+ const isSelfLayout = isBlock && registrySelfLayout(wctx.registry, name);
449
+ const children = buildChildren(element, width, indent, wctx);
450
+ const content = renderDirectiveContent(name, kind, isBlock, attributes, children, width, indent, wctx, isLayoutScope || isSelfLayout ? layoutResolved : undefined);
451
+ if (isBlock && layoutResolved && !isLayoutScope && !isSelfLayout) {
452
+ return applyLayout(content, layoutResolved, width);
453
+ }
454
+ return content;
455
+ }
456
+ /** The plain text content of an element's descendants, sanitized (used for inline `<code>`, which never carries nested styling). */
457
+ function getPlainText(node) {
458
+ let text = '';
459
+ for (const child of node.children) {
460
+ if (child.type === 'text')
461
+ text += stripControlCharacters(child.value);
462
+ else if (child.type === 'element')
463
+ text += getPlainText(child);
464
+ }
465
+ return text;
466
+ }
467
+ /**
468
+ * A link renders as an OSC 8 hyperlink ONLY when: the color level allows
469
+ * escapes at all, `@markii/core`'s `isSafeUrl` accepts the (resolved)
470
+ * href, AND `./sanitize.js`'s `sanitizeUrlText` leaves the href UNCHANGED —
471
+ * that last check is the one that refuses a href carrying a BEL, ESC, or
472
+ * space rather than silently embedding a sanitized-down version of it,
473
+ * matching this module's `hyperlink.probe.test.ts`. Anything else falls
474
+ * back to plain text: the URL once, if the link's own text already IS the
475
+ * URL, otherwise the text followed by `(url)`.
476
+ */
477
+ function renderLink(node, width, indent, wctx) {
478
+ const hrefRaw = typeof node.properties?.href === 'string' ? node.properties.href : '';
479
+ const resolvedHref = resolveHrefAttribute(hrefRaw, wctx.resolveHref);
480
+ const text = renderInlineChildren(node.children, width, indent, wctx);
481
+ const sanitizedHref = stripControlCharacters(resolvedHref);
482
+ const canHyperlink = wctx.color !== 'none' &&
483
+ resolvedHref.trim() !== '' &&
484
+ isSafeUrl(resolvedHref) &&
485
+ sanitizeUrlText(resolvedHref) === resolvedHref;
486
+ if (canHyperlink)
487
+ return hyperlink(text, resolvedHref, wctx.color);
488
+ const plainText = text.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x07\x1b]*\x07/g, '');
489
+ if (plainText === sanitizedHref)
490
+ return sanitizedHref;
491
+ return `${text} (${sanitizedHref})`;
492
+ }
493
+ function renderImage(node, wctx) {
494
+ const altRaw = typeof node.properties?.alt === 'string' ? node.properties.alt : '';
495
+ const alt = stripControlCharacters(altRaw);
496
+ const srcRaw = typeof node.properties?.src === 'string' ? node.properties.src : '';
497
+ const resolvedSrc = resolveImageAttribute(srcRaw, wctx.resolveImageSrc);
498
+ const src = stripControlCharacters(resolvedSrc);
499
+ return `[${alt}] (${src})`;
500
+ }
501
+ function renderInlineNode(node, width, indent, wctx) {
502
+ if (node.type === 'text')
503
+ return stripControlCharacters(node.value);
504
+ if (node.type !== 'element')
505
+ return '';
506
+ switch (node.tagName) {
507
+ case DIRECTIVE_TAG:
508
+ return renderDirective(node, width, indent, wctx);
509
+ case 'strong':
510
+ return bold(renderInlineChildren(node.children, width, indent, wctx), wctx.color);
511
+ case 'em':
512
+ return italic(renderInlineChildren(node.children, width, indent, wctx), wctx.color);
513
+ case 'del':
514
+ return dim(`~${renderInlineChildren(node.children, width, indent, wctx)}~`, wctx.color);
515
+ case 'code': {
516
+ const inner = getPlainText(node);
517
+ return wctx.color === 'none'
518
+ ? dim(inner, wctx.color)
519
+ : inverse(inner, wctx.color);
520
+ }
521
+ case 'a':
522
+ return renderLink(node, width, indent, wctx);
523
+ case 'img':
524
+ return renderImage(node, wctx);
525
+ case 'br':
526
+ return '\n';
527
+ case 'input':
528
+ // Task-list checkboxes are consumed by `renderListItem`; a stray one
529
+ // (outside a list item) produces nothing rather than leaking markup.
530
+ return '';
531
+ default:
532
+ return renderInlineChildren(node.children ?? [], width, indent, wctx);
533
+ }
534
+ }
535
+ function renderInlineChildren(children, width, indent, wctx) {
536
+ let result = '';
537
+ for (const child of children)
538
+ result += renderInlineNode(child, width, indent, wctx);
539
+ return result;
540
+ }
541
+ function renderHeading(el, width, indent, wctx) {
542
+ const level = Number(el.tagName.slice(1));
543
+ const text = renderInlineChildren(el.children, width, indent, wctx);
544
+ const styled = level === 1
545
+ ? underline(bold(text, wctx.color), wctx.color)
546
+ : bold(text, wctx.color);
547
+ return wrap(styled, width).join('\n');
548
+ }
549
+ function renderParagraph(el, width, indent, wctx) {
550
+ const text = renderInlineChildren(el.children, width, indent, wctx);
551
+ return wrap(text, width).join('\n');
552
+ }
553
+ function renderBlockquote(el, width, indent, wctx) {
554
+ const innerWidth = Math.max(1, width - 2);
555
+ const inner = renderBlocks(el.children, innerWidth, `${indent}│ `, wctx);
556
+ const prefix = dim('│ ', wctx.color);
557
+ return inner
558
+ .split('\n')
559
+ .map((line) => `${prefix}${line}`)
560
+ .join('\n');
561
+ }
562
+ /** The task-checkbox state of a list item, or `undefined` for an ordinary item. */
563
+ function taskState(li) {
564
+ for (const child of li.children) {
565
+ if (child.type !== 'element')
566
+ continue;
567
+ if (child.tagName === 'input') {
568
+ return { checked: Boolean(child.properties?.checked) };
569
+ }
570
+ // A task checkbox is always the first element in `remark-gfm`'s output;
571
+ // any other element first means this is not a task item.
572
+ return undefined;
573
+ }
574
+ return undefined;
575
+ }
576
+ function renderListItem(li, marker, width, indent, wctx) {
577
+ const task = taskState(li);
578
+ const contentChildren = li.children.filter((child) => !(task && child.type === 'element' && child.tagName === 'input'));
579
+ const nestedLists = [];
580
+ const restChildren = [];
581
+ for (const child of contentChildren) {
582
+ if (child.type === 'element' &&
583
+ (child.tagName === 'ul' || child.tagName === 'ol')) {
584
+ nestedLists.push(child);
585
+ }
586
+ else {
587
+ restChildren.push(child);
588
+ }
589
+ }
590
+ const prefix = `${marker}${task ? (task.checked ? '[x] ' : '[ ] ') : ''}`;
591
+ const prefixWidth = measure(prefix);
592
+ const bodyWidth = Math.max(1, width - prefixWidth);
593
+ const bodyText = isBlockLevelChildren(restChildren)
594
+ ? renderBlocks(restChildren, bodyWidth, `${indent}${' '.repeat(prefixWidth)}`, wctx)
595
+ : wrap(renderInlineChildren(restChildren, bodyWidth, indent, wctx), bodyWidth).join('\n');
596
+ const bodyLines = bodyText === '' ? [''] : bodyText.split('\n');
597
+ const firstLine = `${prefix}${bodyLines[0] ?? ''}`;
598
+ const restLines = bodyLines
599
+ .slice(1)
600
+ .map((line) => `${' '.repeat(prefixWidth)}${line}`);
601
+ let result = [firstLine, ...restLines].join('\n');
602
+ for (const nested of nestedLists) {
603
+ const nestedText = renderBlock(nested, Math.max(1, width - 2), `${indent} `, wctx);
604
+ result += `\n${nestedText
605
+ .split('\n')
606
+ .map((line) => ` ${line}`)
607
+ .join('\n')}`;
608
+ }
609
+ return result;
610
+ }
611
+ function renderList(el, width, indent, wctx) {
612
+ const ordered = el.tagName === 'ol';
613
+ const startRaw = el.properties?.start;
614
+ const start = ordered && typeof startRaw === 'number' ? startRaw : 1;
615
+ const items = el.children.filter((child) => child.type === 'element' && child.tagName === 'li');
616
+ return items
617
+ .map((li, index) => {
618
+ const marker = ordered ? `${start + index}. ` : '- ';
619
+ return renderListItem(li, marker, width, indent, wctx);
620
+ })
621
+ .join('\n');
622
+ }
623
+ function findChildElement(node, tagName) {
624
+ for (const child of node.children) {
625
+ if (child.type === 'element' && child.tagName === tagName)
626
+ return child;
627
+ }
628
+ return undefined;
629
+ }
630
+ function getLanguage(codeNode) {
631
+ const className = codeNode?.properties?.className;
632
+ const list = Array.isArray(className) ? className : [];
633
+ for (const name of list) {
634
+ if (typeof name === 'string' && name.startsWith('language-')) {
635
+ return name.slice('language-'.length);
636
+ }
637
+ }
638
+ return '';
639
+ }
640
+ function getCodeText(codeNode) {
641
+ if (!codeNode)
642
+ return '';
643
+ let text = '';
644
+ for (const child of codeNode.children) {
645
+ if (child.type === 'text')
646
+ text += child.value;
647
+ }
648
+ return text.endsWith('\n') ? text.slice(0, -1) : text;
649
+ }
650
+ /**
651
+ * Folds a script code block into a one-line collapsed marker, matching
652
+ * `@markii/html`'s `renderScriptMarker` summary wording exactly (a
653
+ * terminal has no expand/collapse affordance, so the body is never shown —
654
+ * see this module's top comment on interactive components). Returns
655
+ * `undefined` for any other `<pre>` so the caller renders it as an
656
+ * ordinary code block.
657
+ */
658
+ function renderScriptMarker(node, color) {
659
+ try {
660
+ const codeNode = findChildElement(node, 'code');
661
+ const meta = codeNode
662
+ ? (codeNode.properties?.[CODE_META_ATTR] ?? undefined)
663
+ : undefined;
664
+ if (typeof meta !== 'string')
665
+ return undefined;
666
+ const attrs = parseMetaAttributes(meta);
667
+ const name = attrs.name;
668
+ if (!name || !isValidScriptName(name))
669
+ return undefined;
670
+ const src = attrs.src || undefined;
671
+ const detail = src ?? getLanguage(codeNode);
672
+ const summary = detail ? `⚙ ${name} · ${detail}` : `⚙ ${name}`;
673
+ return dim(summary, color);
674
+ }
675
+ catch {
676
+ return undefined;
677
+ }
678
+ }
679
+ function renderCodeBlock(node, color) {
680
+ const codeNode = findChildElement(node, 'code');
681
+ const language = getLanguage(codeNode);
682
+ const text = sanitizeBlockText(getCodeText(codeNode));
683
+ const lines = [];
684
+ if (language)
685
+ lines.push(dim(` ${language}`, color));
686
+ for (const line of text.split('\n'))
687
+ lines.push(dim(` ${line}`, color));
688
+ return lines.join('\n');
689
+ }
690
+ /**
691
+ * An ordinary GFM markdown table, box-drawn with a bold header row through
692
+ * `./components/table-grid.js`'s `drawTableGrid` — the SAME box-drawing and
693
+ * width-negotiation routine the data-bound `::table` component
694
+ * (`./components/table.ts`) draws with, so this engine has exactly one
695
+ * table-drawing implementation rather than two. Rendered at the directive's
696
+ * real available `width`, not a hardcoded budget: each cell's inline
697
+ * markdown is rendered once at that width so its own links/emphasis/code
698
+ * are preserved, and `drawTableGrid` then negotiates how much of `width`
699
+ * each column actually gets.
700
+ */
701
+ function renderTable(tableEl, width, wctx) {
702
+ const headerCells = [];
703
+ const bodyRows = [];
704
+ let sawHeader = false;
705
+ const collectRow = (tr, into) => {
706
+ for (const cell of tr.children) {
707
+ if (cell.type !== 'element')
708
+ continue;
709
+ if (cell.tagName !== 'td' && cell.tagName !== 'th')
710
+ continue;
711
+ into.push(renderInlineChildren(cell.children, width, '', wctx));
712
+ }
713
+ };
714
+ for (const section of tableEl.children) {
715
+ if (section.type !== 'element')
716
+ continue;
717
+ const rows = section.tagName === 'thead' || section.tagName === 'tbody'
718
+ ? section.children.filter((row) => row.type === 'element' && row.tagName === 'tr')
719
+ : section.tagName === 'tr'
720
+ ? [section]
721
+ : [];
722
+ for (const tr of rows) {
723
+ if (section.tagName === 'thead' && !sawHeader) {
724
+ collectRow(tr, headerCells);
725
+ sawHeader = true;
726
+ }
727
+ else {
728
+ const row = [];
729
+ collectRow(tr, row);
730
+ bodyRows.push(row);
731
+ }
732
+ }
733
+ }
734
+ return drawTableGrid(headerCells.length > 0 ? headerCells : undefined, bodyRows, width, (text) => bold(text, wctx.color));
735
+ }
736
+ function renderBlock(node, width, indent, wctx) {
737
+ if (node.type === 'text')
738
+ return stripControlCharacters(node.value);
739
+ if (node.type !== 'element')
740
+ return '';
741
+ if (node.tagName === DIRECTIVE_TAG)
742
+ return renderDirective(node, width, indent, wctx);
743
+ if (/^h[1-6]$/.test(node.tagName))
744
+ return renderHeading(node, width, indent, wctx);
745
+ if (node.tagName === 'p')
746
+ return renderParagraph(node, width, indent, wctx);
747
+ if (node.tagName === 'blockquote')
748
+ return renderBlockquote(node, width, indent, wctx);
749
+ if (node.tagName === 'ul' || node.tagName === 'ol')
750
+ return renderList(node, width, indent, wctx);
751
+ if (node.tagName === 'hr')
752
+ return dim(rule(width), wctx.color);
753
+ if (node.tagName === 'table')
754
+ return renderTable(node, width, wctx);
755
+ if (node.tagName === 'pre') {
756
+ const marker = renderScriptMarker(node, wctx.color);
757
+ if (marker !== undefined)
758
+ return marker;
759
+ return renderCodeBlock(node, wctx.color);
760
+ }
761
+ return wrap(renderInlineChildren(node.children ?? [], width, indent, wctx), width).join('\n');
762
+ }
763
+ function renderBlocks(nodes, width, indent, wctx) {
764
+ const blocks = [];
765
+ for (const node of nodes) {
766
+ const rendered = renderBlock(node, width, indent, wctx);
767
+ // Whitespace-only output is dropped, not just the empty string.
768
+ // remark-rehype puts a formatting `\n` TEXT node between every pair of
769
+ // block elements, and `stripControlCharacters` turns a line feed into a
770
+ // space rather than deleting it (see its doc comment: inline text reads
771
+ // better that way). Kept, each of those would become a block of its own
772
+ // holding one space, so every pair of real blocks would be separated by
773
+ // a blank line, a space line, and another blank line instead of a single
774
+ // blank line. Nothing a reader can see is ever lost by this test: a
775
+ // block whose entire rendered form is whitespace carries no content.
776
+ if (rendered.trim() !== '')
777
+ blocks.push(rendered);
778
+ }
779
+ return blocks.join('\n\n');
780
+ }
781
+ /** Collapses trailing blank lines to exactly one terminating `\n`, and leaves an empty document as `''`. */
782
+ function finalizeOutput(text) {
783
+ if (text === '')
784
+ return '';
785
+ return `${text.replace(/\n+$/, '')}\n`;
786
+ }
787
+ /** The shared "failed to render" fallback: one quiet dim line naming the failure, plus the message on its own line. Never a stack trace. */
788
+ function renderFailureFallback(error, color) {
789
+ const message = error instanceof Error ? error.message : String(error);
790
+ return `${dim('failed to render document', color)}\n${stripControlCharacters(message)}\n`;
791
+ }
792
+ function renderRoot(root, registry, scope, color, theme, resolveImageSrc, resolveHref, onDiagnostic, width) {
793
+ const wctx = {
794
+ registry,
795
+ scope,
796
+ color,
797
+ theme,
798
+ resolveImageSrc,
799
+ resolveHref,
800
+ onDiagnostic,
801
+ };
802
+ return finalizeOutput(renderBlocks(root.children, width, '', wctx));
803
+ }
804
+ /** The columns assumed when a caller supplies no `width` option. */
805
+ const DEFAULT_WIDTH = 80;
806
+ /**
807
+ * Renders Markii text to a plain string (optionally carrying ANSI SGR/OSC 8
808
+ * escapes) using `registry` to resolve directive names. `registry` is
809
+ * OPTIONAL here, unlike the other two engines' required parameter, and
810
+ * defaults to `defaultAnsiRegistry` (empty in this phase; every directive
811
+ * therefore renders the unknown-component fallback).
812
+ *
813
+ * Pipeline: `@markii/core`'s `toHast` (parse -> tag directive nodes ->
814
+ * remark-rehype -> sanitize URLs) -> a width-aware hast walk that swaps
815
+ * directive elements for registry components (or the unknown-directive
816
+ * fallback), folds script fences into a one-line marker, and wraps every
817
+ * paragraph, heading, and list item to `options.width` (default 80). Pure
818
+ * and never-throwing: parsing is tolerant, unknown names always render a
819
+ * fallback, and any unexpected internal error degrades to the "failed to
820
+ * render document" line.
821
+ *
822
+ * `options.color` resolves via `./ansi.js`'s `resolveColorOption`: `'auto'`
823
+ * and `undefined` both mean "no escapes" here, since this engine has no
824
+ * environment knowledge of its own — a caller that wants automatic
825
+ * detection calls `detectColorLevel` itself and passes the result as
826
+ * `'16'`/`'256'`/`'truecolor'`.
827
+ *
828
+ * `store`/`vault` and `resolveImageSrc`/`resolveHref`/`onDiagnostic` carry
829
+ * the same meaning as `@markii/html`'s identical options.
830
+ */
831
+ export function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
832
+ const color = resolveColorOption(options?.color);
833
+ try {
834
+ return renderRoot(toHast(text), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, options?.width ?? DEFAULT_WIDTH);
835
+ }
836
+ catch (error) {
837
+ return renderFailureFallback(error, color);
838
+ }
839
+ }
840
+ /**
841
+ * The block-level twin of `renderMarkToAnsi`: renders one already-parsed
842
+ * mdast node OR a whole already-parsed mdast document (`@markii/core`'s
843
+ * `MarkNode`, or the `Root` `parse` itself returns) instead of raw document
844
+ * text. Same registry resolution, same fallbacks, same purity and
845
+ * never-throw guarantees, same options.
846
+ */
847
+ export function renderMarkNodeToAnsi(node, registry = defaultAnsiRegistry, store, vault, options) {
848
+ const color = resolveColorOption(options?.color);
849
+ try {
850
+ return renderRoot(nodeOrRootToHast(node), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, options?.width ?? DEFAULT_WIDTH);
851
+ }
852
+ catch (error) {
853
+ return renderFailureFallback(error, color);
854
+ }
855
+ }
856
+ /** Whether `root` is exactly one paragraph holding exactly one text directive (`:name[...]`) and nothing else. Mirrors `@markii/html`'s identical `loneInlineDirective`. */
857
+ function loneInlineDirective(root) {
858
+ if (root.children.length !== 1)
859
+ return undefined;
860
+ const [only] = root.children;
861
+ if (!only || only.type !== 'paragraph')
862
+ return undefined;
863
+ if (only.children.length !== 1)
864
+ return undefined;
865
+ const [inner] = only.children;
866
+ return inner && inner.type === 'textDirective'
867
+ ? inner
868
+ : undefined;
869
+ }
870
+ /**
871
+ * Renders `text` as a single, standalone inline directive when that is all
872
+ * it is, mirroring `@markii/html`'s identical `renderMarkInlineToHtml`: a
873
+ * source whose only parsed block is a paragraph holding exactly one text
874
+ * directive renders that directive alone. Any other source falls back to
875
+ * the exact same rendering `renderMarkToAnsi` would produce.
876
+ */
877
+ export function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
878
+ try {
879
+ const root = parse(text);
880
+ const lone = loneInlineDirective(root);
881
+ if (lone) {
882
+ return renderMarkNodeToAnsi(lone, registry, store, vault, options);
883
+ }
884
+ }
885
+ catch {
886
+ // Falls through to the ordinary whole-document render below.
887
+ }
888
+ return renderMarkToAnsi(text, registry, store, vault, options);
889
+ }