@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.
- package/dist/ansi.d.ts +79 -0
- package/dist/ansi.js +136 -0
- package/dist/box.d.ts +47 -0
- package/dist/box.js +209 -0
- package/dist/components/badge.d.ts +10 -0
- package/dist/components/badge.js +44 -0
- package/dist/components/callout.d.ts +20 -0
- package/dist/components/callout.js +71 -0
- package/dist/components/card.d.ts +12 -0
- package/dist/components/card.js +44 -0
- package/dist/components/cell.d.ts +18 -0
- package/dist/components/cell.js +31 -0
- package/dist/components/chart.d.ts +15 -0
- package/dist/components/chart.js +129 -0
- package/dist/components/details.d.ts +12 -0
- package/dist/components/details.js +25 -0
- package/dist/components/divider.d.ts +4 -0
- package/dist/components/divider.js +80 -0
- package/dist/components/figure.d.ts +20 -0
- package/dist/components/figure.js +53 -0
- package/dist/components/index.d.ts +34 -0
- package/dist/components/index.js +109 -0
- package/dist/components/kbd.d.ts +7 -0
- package/dist/components/kbd.js +8 -0
- package/dist/components/layout-wrapper.d.ts +35 -0
- package/dist/components/layout-wrapper.js +62 -0
- package/dist/components/progress.d.ts +15 -0
- package/dist/components/progress.js +78 -0
- package/dist/components/rating.d.ts +9 -0
- package/dist/components/rating.js +28 -0
- package/dist/components/row.d.ts +26 -0
- package/dist/components/row.js +67 -0
- package/dist/components/stat.d.ts +13 -0
- package/dist/components/stat.js +79 -0
- package/dist/components/tab.d.ts +13 -0
- package/dist/components/tab.js +17 -0
- package/dist/components/table-grid.d.ts +34 -0
- package/dist/components/table-grid.js +124 -0
- package/dist/components/table.d.ts +16 -0
- package/dist/components/table.js +101 -0
- package/dist/components/tabs.d.ts +18 -0
- package/dist/components/tabs.js +30 -0
- package/dist/failure-presentation.d.ts +58 -0
- package/dist/failure-presentation.js +124 -0
- package/dist/href-resolve.d.ts +21 -0
- package/dist/href-resolve.js +25 -0
- package/dist/image-resolve.d.ts +41 -0
- package/dist/image-resolve.js +28 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/layout.d.ts +73 -0
- package/dist/layout.js +144 -0
- package/dist/measure.d.ts +25 -0
- package/dist/measure.js +108 -0
- package/dist/registry.d.ts +227 -0
- package/dist/registry.js +121 -0
- package/dist/render.d.ts +69 -0
- package/dist/render.js +889 -0
- package/dist/resolve.d.ts +60 -0
- package/dist/resolve.js +152 -0
- package/dist/sanitize.d.ts +51 -0
- package/dist/sanitize.js +101 -0
- package/dist/style.d.ts +9 -0
- package/dist/style.js +13 -0
- package/dist/theme.d.ts +36 -0
- package/dist/theme.js +79 -0
- package/dist/url-resolve.d.ts +54 -0
- package/dist/url-resolve.js +82 -0
- package/dist/value-format.d.ts +13 -0
- package/dist/value-format.js +16 -0
- package/dist/value-types.d.ts +37 -0
- package/dist/value-types.js +17 -0
- 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
|
+
}
|