@bendyline/squisq 1.5.0 → 1.5.2
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/README.md +14 -1
- package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
- package/dist/{Doc-BsMkmzH8.d.ts → Doc-BBVGq1_9.d.ts} +94 -15
- package/dist/{ImageEditDoc-DvoeHMP9.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +1 -1
- package/dist/{chunk-4ZFRZBU2.js → chunk-5TYS5V3G.js} +866 -192
- package/dist/chunk-5TYS5V3G.js.map +1 -0
- package/dist/{chunk-7MQGNR76.js → chunk-A4O7GIWE.js} +2 -27
- package/dist/chunk-A4O7GIWE.js.map +1 -0
- package/dist/{chunk-OH43RPTP.js → chunk-BEQODJWV.js} +20 -3
- package/dist/chunk-BEQODJWV.js.map +1 -0
- package/dist/chunk-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/{chunk-KZNXZ5SM.js → chunk-GPLTCSXO.js} +18 -7
- package/dist/chunk-GPLTCSXO.js.map +1 -0
- package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
- package/dist/chunk-HWVFJAAH.js.map +1 -0
- package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
- package/dist/chunk-IMSCRLLV.js.map +1 -0
- package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
- package/dist/chunk-KKNUBQ6Y.js.map +1 -0
- package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
- package/dist/{chunk-RUDYOTA6.js → chunk-LTN6I7QW.js} +56 -1
- package/dist/chunk-LTN6I7QW.js.map +1 -0
- package/dist/{chunk-2OIBZYKP.js → chunk-P3K7NLHF.js} +2 -2
- package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.js.map +1 -0
- package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
- package/dist/chunk-TQWLI6S2.js.map +1 -0
- package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
- package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
- package/dist/chunk-VWUFZ6ZG.js.map +1 -0
- package/dist/doc/index.d.ts +4 -4
- package/dist/doc/index.js +24 -9
- package/dist/generate/index.d.ts +2 -80
- package/dist/generate/index.js +2 -7
- package/dist/imageEdit/index.d.ts +4 -4
- package/dist/imageEdit/index.js +2 -2
- package/dist/index.d.ts +8 -8
- package/dist/index.js +41 -26
- package/dist/jsonForm/index.d.ts +42 -1
- package/dist/jsonForm/index.js +9 -1
- package/dist/markdown/index.d.ts +18 -2
- package/dist/markdown/index.js +5 -3
- package/dist/recommend/index.js +2 -2
- package/dist/schemas/index.d.ts +4 -4
- package/dist/schemas/index.js +3 -5
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +1 -1
- package/dist/story/index.d.ts +151 -13
- package/dist/story/index.js +24 -9
- package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
- package/dist/timing/index.js +3 -5
- package/dist/transform/index.d.ts +2 -2
- package/dist/transform/index.js +3 -3
- package/dist/versions/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/contentExtractor.test.ts +60 -0
- package/src/__tests__/coverBlock.test.ts +79 -3
- package/src/__tests__/customTemplateSample.test.ts +63 -2
- package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
- package/src/__tests__/inputDescriptors.test.ts +162 -0
- package/src/__tests__/jsonForm.tokens.test.ts +84 -0
- package/src/__tests__/markdown.test.ts +64 -0
- package/src/__tests__/markdownSanitize.test.ts +15 -0
- package/src/__tests__/markdownToDoc.test.ts +173 -2
- package/src/__tests__/mediaAnnotations.test.ts +27 -0
- package/src/__tests__/shapeGeometry.test.ts +37 -0
- package/src/__tests__/structuredData.test.ts +73 -3
- package/src/__tests__/templateMetadata.test.ts +11 -1
- package/src/__tests__/templates.test.ts +112 -0
- package/src/__tests__/validateDoc.test.ts +160 -0
- package/src/doc/annotationBlocks.ts +117 -0
- package/src/doc/customTemplatesFrontmatter.ts +7 -1
- package/src/doc/customThemesFrontmatter.ts +6 -1
- package/src/doc/docToMarkdown.ts +78 -29
- package/src/doc/getLayers.ts +11 -2
- package/src/doc/index.ts +12 -0
- package/src/doc/markdownToDoc.ts +80 -3
- package/src/doc/mediaAnnotations.ts +15 -33
- package/src/doc/standaloneAnnotation.ts +58 -0
- package/src/doc/structuredData.ts +92 -21
- package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
- package/src/doc/templates/comparisonBar.ts +8 -1
- package/src/doc/templates/coverBlock.ts +78 -15
- package/src/doc/templates/customTemplate.ts +10 -4
- package/src/doc/templates/diagramBlock.ts +2 -2
- package/src/doc/templates/drawingBlock.ts +2 -2
- package/src/doc/templates/factCard.ts +4 -2
- package/src/doc/templates/imageWithCaption.ts +10 -8
- package/src/doc/templates/index.ts +12 -2
- package/src/doc/templates/inputDescriptors.ts +432 -0
- package/src/doc/templates/photoGrid.ts +16 -39
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
- package/src/doc/templates/tokens/resolveTokens.ts +0 -0
- package/src/doc/templates/videoWithCaption.ts +15 -14
- package/src/doc/utils/nearest.ts +59 -0
- package/src/doc/utils/shapeGeometry.ts +90 -2
- package/src/doc/validate.ts +153 -54
- package/src/generate/contentExtractor.ts +148 -35
- package/src/generate/index.ts +0 -3
- package/src/jsonForm/index.ts +2 -0
- package/src/jsonForm/tokens.ts +78 -0
- package/src/markdown/annotationCoercion.ts +1 -1
- package/src/markdown/attrTokens.ts +19 -0
- package/src/markdown/convert.ts +11 -1
- package/src/markdown/index.ts +2 -1
- package/src/markdown/sanitize.ts +22 -2
- package/src/markdown/stringify.ts +12 -3
- package/src/markdown/utils.ts +62 -1
- package/src/schemas/CustomTemplates.ts +56 -4
- package/src/schemas/Doc.ts +39 -41
- package/src/schemas/themeCompile.ts +1 -1
- package/src/storage/ContentContainer.ts +6 -2
- package/src/transform/types.ts +1 -1
- package/dist/chunk-2TMAWB4Y.js.map +0 -1
- package/dist/chunk-3E5F2XMR.js.map +0 -1
- package/dist/chunk-4AKUQPEE.js.map +0 -1
- package/dist/chunk-4X3JQXNM.js +0 -35
- package/dist/chunk-4X3JQXNM.js.map +0 -1
- package/dist/chunk-4ZFRZBU2.js.map +0 -1
- package/dist/chunk-7MQGNR76.js.map +0 -1
- package/dist/chunk-FBKP5CAO.js +0 -145
- package/dist/chunk-FBKP5CAO.js.map +0 -1
- package/dist/chunk-FR2RBTKO.js.map +0 -1
- package/dist/chunk-K32VJONL.js.map +0 -1
- package/dist/chunk-KZNXZ5SM.js.map +0 -1
- package/dist/chunk-OH43RPTP.js.map +0 -1
- package/dist/chunk-OKJOQXK4.js.map +0 -1
- package/dist/chunk-RUDYOTA6.js.map +0 -1
- package/src/__tests__/slideshowGenerator.test.ts +0 -128
- package/src/generate/slideshowGenerator.ts +0 -280
- /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
- /package/dist/{chunk-2OIBZYKP.js.map → chunk-P3K7NLHF.js.map} +0 -0
- /package/dist/{chunk-LRCV7N7F.js.map → chunk-UY7KGQ5R.js.map} +0 -0
package/src/doc/validate.ts
CHANGED
|
@@ -28,8 +28,12 @@ import type {
|
|
|
28
28
|
} from '../markdown/types.js';
|
|
29
29
|
import { parseMarkdown } from '../markdown/parse.js';
|
|
30
30
|
import { getChildren, extractPlainText } from '../markdown/utils.js';
|
|
31
|
-
import { coerceAnnotationValues } from '../markdown/annotationCoercion.js';
|
|
31
|
+
import { coerceAnnotationValues, KNOWN_BLOCK_META_KEYS } from '../markdown/annotationCoercion.js';
|
|
32
32
|
import { markdownToDoc, flattenBlocks } from './markdownToDoc.js';
|
|
33
|
+
import { parseStandaloneAnnotation } from './standaloneAnnotation.js';
|
|
34
|
+
import { isDataFence, parseDataFence } from './structuredData.js';
|
|
35
|
+
import { deriveTemplateInputs } from './templateInputs.js';
|
|
36
|
+
import { lintTemplateParams } from './templates/inputDescriptors.js';
|
|
33
37
|
import {
|
|
34
38
|
templateRegistry,
|
|
35
39
|
TEMPLATE_ALIASES,
|
|
@@ -38,6 +42,7 @@ import {
|
|
|
38
42
|
normalizeShapeKind,
|
|
39
43
|
SHAPE_NAMES,
|
|
40
44
|
} from './templates/index.js';
|
|
45
|
+
import { nearestName } from './utils/nearest.js';
|
|
41
46
|
|
|
42
47
|
// ============================================
|
|
43
48
|
// Options & result
|
|
@@ -64,6 +69,7 @@ export interface MarkdownValidationResult {
|
|
|
64
69
|
diagnostics: DocDiagnostic[];
|
|
65
70
|
errorCount: number;
|
|
66
71
|
warningCount: number;
|
|
72
|
+
infoCount: number;
|
|
67
73
|
/** The converted Doc (conversion diagnostics are included above). */
|
|
68
74
|
doc: Doc;
|
|
69
75
|
}
|
|
@@ -95,6 +101,9 @@ export function validateMarkdownDoc(
|
|
|
95
101
|
const blocks = flattenBlocks(doc.blocks);
|
|
96
102
|
|
|
97
103
|
const knownTemplates = collectKnownTemplates(doc, options);
|
|
104
|
+
// Custom template names are skipped by the input linter — they carry
|
|
105
|
+
// arbitrary author-defined inputs the built-in descriptors can't know about.
|
|
106
|
+
const customNames = new Set<string>(doc.customTemplates?.map((t) => t.name) ?? []);
|
|
98
107
|
|
|
99
108
|
// Parent-aware walk: a `{[shape]}` annotation is only meaningful on the
|
|
100
109
|
// direct child of a `{[drawing]}` block, so the template check needs each
|
|
@@ -102,6 +111,9 @@ export function validateMarkdownDoc(
|
|
|
102
111
|
const visit = (block: Block, parent: Block | undefined): void => {
|
|
103
112
|
checkTemplate(block, parent, knownTemplates, diagnostics);
|
|
104
113
|
checkAttributeCoercion(block, parent, diagnostics);
|
|
114
|
+
checkConflictingAnnotationKeys(block, diagnostics);
|
|
115
|
+
checkTemplateInputs(block, parent, customNames, diagnostics);
|
|
116
|
+
checkPossibleDataFences(block, diagnostics);
|
|
105
117
|
if (isDrawingBlock(block)) checkDrawingConnectors(block, diagnostics);
|
|
106
118
|
for (const child of block.children ?? []) visit(child, block);
|
|
107
119
|
};
|
|
@@ -122,6 +134,7 @@ export function validateMarkdownDoc(
|
|
|
122
134
|
diagnostics,
|
|
123
135
|
errorCount: diagnostics.filter((d) => d.severity === 'error').length,
|
|
124
136
|
warningCount: diagnostics.filter((d) => d.severity === 'warning').length,
|
|
137
|
+
infoCount: diagnostics.filter((d) => d.severity === 'info').length,
|
|
125
138
|
doc,
|
|
126
139
|
};
|
|
127
140
|
}
|
|
@@ -150,13 +163,19 @@ function checkTemplate(
|
|
|
150
163
|
known: Set<string>,
|
|
151
164
|
diagnostics: DocDiagnostic[],
|
|
152
165
|
): void {
|
|
153
|
-
|
|
166
|
+
// Heading blocks carry the requested name on the heading annotation;
|
|
167
|
+
// heading-less standalone blocks carry it on `sourceAnnotation` (raw,
|
|
168
|
+
// un-resolved) — read whichever is present.
|
|
169
|
+
const requested =
|
|
170
|
+
block.sourceHeading?.templateAnnotation?.template ?? block.sourceAnnotation?.template;
|
|
154
171
|
if (!requested) return;
|
|
155
172
|
|
|
156
173
|
// Inside a drawing, the annotation names a shape primitive, not a template.
|
|
157
174
|
if (parent && isDrawingBlock(parent)) {
|
|
158
175
|
if (isShapeName(requested)) return;
|
|
159
|
-
const suggestion = nearestName(requested, new Set(SHAPE_NAMES)
|
|
176
|
+
const suggestion = nearestName(requested, new Set(SHAPE_NAMES), {
|
|
177
|
+
map: resolveTemplateName,
|
|
178
|
+
});
|
|
160
179
|
diagnostics.push({
|
|
161
180
|
severity: 'warning',
|
|
162
181
|
code: 'unknown-shape',
|
|
@@ -183,7 +202,7 @@ function checkTemplate(
|
|
|
183
202
|
|
|
184
203
|
if (known.has(requested) || known.has(resolveTemplateName(requested))) return;
|
|
185
204
|
|
|
186
|
-
const suggestion = nearestName(requested, known);
|
|
205
|
+
const suggestion = nearestName(requested, known, { map: resolveTemplateName });
|
|
187
206
|
diagnostics.push({
|
|
188
207
|
severity: 'warning',
|
|
189
208
|
code: 'unknown-template',
|
|
@@ -195,6 +214,60 @@ function checkTemplate(
|
|
|
195
214
|
});
|
|
196
215
|
}
|
|
197
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Lint a template block's `{[…]}` inputs against the built-in input
|
|
219
|
+
* descriptors: unknown keys (with a did-you-mean), values outside a closed
|
|
220
|
+
* enum / that fail coercion, and missing required inputs. All are warnings.
|
|
221
|
+
*
|
|
222
|
+
* Only built-in templates with descriptors are checked — shapes, drawing
|
|
223
|
+
* children, custom templates, and not-yet-described built-ins are skipped
|
|
224
|
+
* (`lintTemplateParams` returns `[]` for the latter). A `missing-input`
|
|
225
|
+
* finding is suppressed when the value is actually satisfiable from the
|
|
226
|
+
* block's own fields, structured data, or body derivation.
|
|
227
|
+
*/
|
|
228
|
+
function checkTemplateInputs(
|
|
229
|
+
block: Block,
|
|
230
|
+
parent: Block | undefined,
|
|
231
|
+
customNames: ReadonlySet<string>,
|
|
232
|
+
diagnostics: DocDiagnostic[],
|
|
233
|
+
): void {
|
|
234
|
+
const requested = block.sourceHeading?.templateAnnotation?.template;
|
|
235
|
+
if (!requested) return;
|
|
236
|
+
// Inside a drawing, the annotation names a shape primitive, not a template.
|
|
237
|
+
if (parent && isDrawingBlock(parent)) return;
|
|
238
|
+
if (isShapeName(requested)) return;
|
|
239
|
+
|
|
240
|
+
const canonical = resolveTemplateName(requested);
|
|
241
|
+
if (customNames.has(requested) || customNames.has(canonical)) return;
|
|
242
|
+
|
|
243
|
+
const params = block.sourceHeading?.templateAnnotation?.params ?? {};
|
|
244
|
+
for (const finding of lintTemplateParams(canonical, params)) {
|
|
245
|
+
if (finding.kind === 'missing-input' && isInputSatisfiable(canonical, block, finding.key)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
diagnostics.push({
|
|
249
|
+
severity: 'warning',
|
|
250
|
+
code: finding.kind,
|
|
251
|
+
message: `Block "${block.id}": ${finding.message}`,
|
|
252
|
+
blockId: block.id,
|
|
253
|
+
...lineOf(block),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether a required template input is satisfiable even though it's absent
|
|
260
|
+
* from the `{[…]}` params: present in a structured-data fence, carried as a
|
|
261
|
+
* block-level field (e.g. `title` from the heading), or derivable from the
|
|
262
|
+
* heading + body (`deriveTemplateInputs` in strict mode).
|
|
263
|
+
*/
|
|
264
|
+
function isInputSatisfiable(canonical: string, block: Block, key: string): boolean {
|
|
265
|
+
if (block.templateData && key in block.templateData) return true;
|
|
266
|
+
if ((block as unknown as Record<string, unknown>)[key] != null) return true;
|
|
267
|
+
const derived = deriveTemplateInputs(canonical, block.title ?? '', block.contents);
|
|
268
|
+
return derived != null && key in derived;
|
|
269
|
+
}
|
|
270
|
+
|
|
198
271
|
/** Numeric geometry keys carried in a shape's `{[…]}` params. */
|
|
199
272
|
const NUMERIC_SHAPE_KEYS = ['x', 'y', 'width', 'height', 'w', 'h', 'strokeWidth', 'borderRadius'];
|
|
200
273
|
|
|
@@ -240,6 +313,34 @@ function checkAttributeCoercion(
|
|
|
240
313
|
}
|
|
241
314
|
}
|
|
242
315
|
|
|
316
|
+
/**
|
|
317
|
+
* A heading can carry the same block-meta key in both the Pandoc `{k=v}`
|
|
318
|
+
* block and the squiggly `{[tpl k=v]}` annotation. The Pandoc value wins
|
|
319
|
+
* (same precedence as `makeBlock` in markdownToDoc), which is easy to miss
|
|
320
|
+
* when the two disagree — surface an info diagnostic so the author knows
|
|
321
|
+
* which value is in effect.
|
|
322
|
+
*/
|
|
323
|
+
function checkConflictingAnnotationKeys(block: Block, diagnostics: DocDiagnostic[]): void {
|
|
324
|
+
const annotationParams = block.sourceHeading?.templateAnnotation?.params;
|
|
325
|
+
const pandocParams = block.sourceHeading?.attributes?.params;
|
|
326
|
+
if (!annotationParams || !pandocParams) return;
|
|
327
|
+
|
|
328
|
+
for (const key of Object.keys(KNOWN_BLOCK_META_KEYS)) {
|
|
329
|
+
const fromAnnotation = annotationParams[key];
|
|
330
|
+
const fromPandoc = pandocParams[key];
|
|
331
|
+
if (fromAnnotation == null || fromPandoc == null || fromAnnotation === fromPandoc) continue;
|
|
332
|
+
diagnostics.push({
|
|
333
|
+
severity: 'info',
|
|
334
|
+
code: 'conflicting-annotation-key',
|
|
335
|
+
message:
|
|
336
|
+
`"${key}" is set in both {[…]} (${fromAnnotation}) and {…} (${fromPandoc}) — ` +
|
|
337
|
+
`the {…} value wins.`,
|
|
338
|
+
blockId: block.id,
|
|
339
|
+
...lineOf(block),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
243
344
|
/**
|
|
244
345
|
* A drawing's `line`/`arrow` connectors reference sibling shapes by id via
|
|
245
346
|
* `from`/`to`; flag any that don't resolve (mirrors the `connectsTo`
|
|
@@ -269,6 +370,40 @@ function checkDrawingConnectors(block: Block, diagnostics: DocDiagnostic[]): voi
|
|
|
269
370
|
}
|
|
270
371
|
}
|
|
271
372
|
|
|
373
|
+
/**
|
|
374
|
+
* A plain ```json / ```yaml fence (no `data` marker) inside a template-
|
|
375
|
+
* annotated block, whose content parses as a top-level object, is almost
|
|
376
|
+
* certainly meant as template input — surface an info nudge to add the
|
|
377
|
+
* `data` marker. Deliberately scoped to *annotated* blocks so ordinary code
|
|
378
|
+
* samples in prose sections never trigger it.
|
|
379
|
+
*/
|
|
380
|
+
function checkPossibleDataFences(block: Block, diagnostics: DocDiagnostic[]): void {
|
|
381
|
+
const annotated = !!(
|
|
382
|
+
block.sourceHeading?.templateAnnotation?.template || block.sourceAnnotation?.template
|
|
383
|
+
);
|
|
384
|
+
if (!annotated) return;
|
|
385
|
+
|
|
386
|
+
for (const node of block.contents ?? []) {
|
|
387
|
+
if (node.type !== 'code') continue;
|
|
388
|
+
const lang = node.lang?.toLowerCase();
|
|
389
|
+
if (lang !== 'json' && lang !== 'yaml' && lang !== 'yml') continue;
|
|
390
|
+
if (isDataFence(node)) continue; // already a data fence — nothing to nudge
|
|
391
|
+
|
|
392
|
+
const result = parseDataFence(node);
|
|
393
|
+
if (!result.data || Object.keys(result.data).length === 0) continue;
|
|
394
|
+
|
|
395
|
+
diagnostics.push({
|
|
396
|
+
severity: 'info',
|
|
397
|
+
code: 'possible-data-fence',
|
|
398
|
+
message:
|
|
399
|
+
`This ${lang} block renders as code — add "data" to the fence ` +
|
|
400
|
+
`(\`\`\`${lang} data) if it's meant as template input.`,
|
|
401
|
+
blockId: block.id,
|
|
402
|
+
...(node.position ? { line: node.position.start.line } : lineOf(block)),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
272
407
|
// ============================================
|
|
273
408
|
// Unrecognized `{[…]}` text
|
|
274
409
|
// ============================================
|
|
@@ -285,7 +420,7 @@ function checkUnrecognizedAnnotations(
|
|
|
285
420
|
markdownDoc: MarkdownDocument,
|
|
286
421
|
diagnostics: DocDiagnostic[],
|
|
287
422
|
): void {
|
|
288
|
-
const walk = (node: MarkdownNode, headingLine: number | undefined): void => {
|
|
423
|
+
const walk = (node: MarkdownNode, headingLine: number | undefined, topLevel: boolean): void => {
|
|
289
424
|
if (node.type === 'heading') {
|
|
290
425
|
const heading = node as MarkdownHeading;
|
|
291
426
|
const text = extractPlainText(heading);
|
|
@@ -301,13 +436,22 @@ function checkUnrecognizedAnnotations(
|
|
|
301
436
|
}
|
|
302
437
|
return; // heading inline children already covered by extractPlainText
|
|
303
438
|
}
|
|
439
|
+
// A *top-level* paragraph that is exactly a `{[templateName …]}` /
|
|
440
|
+
// `{[audio …]}` annotation is recognized (it becomes a standalone template
|
|
441
|
+
// block or a media clip) — don't flag it, and don't descend into its text.
|
|
442
|
+
// Nested paragraphs (inside a list / blockquote) are NOT extracted, so
|
|
443
|
+
// they still warn.
|
|
444
|
+
if (node.type === 'paragraph' && topLevel) {
|
|
445
|
+
const parsed = parseStandaloneAnnotation(node);
|
|
446
|
+
if (parsed && parsed.template != null) return;
|
|
447
|
+
}
|
|
304
448
|
if (node.type === 'text' && ANNOTATION_LIKE_RE.test(node.value)) {
|
|
305
449
|
diagnostics.push({
|
|
306
450
|
severity: 'warning',
|
|
307
451
|
code: 'unparsed-annotation',
|
|
308
452
|
message:
|
|
309
|
-
`Body text contains "{[…]}" — template annotations are
|
|
310
|
-
`
|
|
453
|
+
`Body text contains "{[…]}" — template annotations are recognized on headings ` +
|
|
454
|
+
`and as standalone paragraphs (or the token is not a known inline icon)`,
|
|
311
455
|
...(node.position
|
|
312
456
|
? { line: node.position.start.line }
|
|
313
457
|
: headingLine != null
|
|
@@ -318,12 +462,12 @@ function checkUnrecognizedAnnotations(
|
|
|
318
462
|
}
|
|
319
463
|
if (node.type === 'code') return; // code blocks legitimately contain {[
|
|
320
464
|
for (const child of getChildren(node)) {
|
|
321
|
-
walk(child, headingLine);
|
|
465
|
+
walk(child, headingLine, false);
|
|
322
466
|
}
|
|
323
467
|
};
|
|
324
468
|
|
|
325
469
|
for (const child of markdownDoc.children) {
|
|
326
|
-
walk(child, child.position?.start.line);
|
|
470
|
+
walk(child, child.position?.start.line, true);
|
|
327
471
|
}
|
|
328
472
|
}
|
|
329
473
|
|
|
@@ -417,51 +561,6 @@ function walkHtmlImages(nodes: HtmlNode[], report: (src: string) => void): void
|
|
|
417
561
|
}
|
|
418
562
|
}
|
|
419
563
|
|
|
420
|
-
// ============================================
|
|
421
|
-
// Did-you-mean
|
|
422
|
-
// ============================================
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Nearest known name by edit distance. Returns undefined when nothing is
|
|
426
|
-
* plausibly close (distance > 2 and > 40% of the input length).
|
|
427
|
-
*/
|
|
428
|
-
function nearestName(input: string, candidates: Set<string>): string | undefined {
|
|
429
|
-
let best: string | undefined;
|
|
430
|
-
let bestDist = Infinity;
|
|
431
|
-
const lower = input.toLowerCase();
|
|
432
|
-
for (const candidate of candidates) {
|
|
433
|
-
const dist = levenshtein(lower, candidate.toLowerCase());
|
|
434
|
-
if (dist < bestDist) {
|
|
435
|
-
bestDist = dist;
|
|
436
|
-
best = candidate;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
if (best && (bestDist <= 2 || bestDist <= Math.ceil(input.length * 0.4))) {
|
|
440
|
-
return resolveTemplateName(best);
|
|
441
|
-
}
|
|
442
|
-
return undefined;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function levenshtein(a: string, b: string): number {
|
|
446
|
-
if (a === b) return 0;
|
|
447
|
-
const m = a.length;
|
|
448
|
-
const n = b.length;
|
|
449
|
-
if (m === 0) return n;
|
|
450
|
-
if (n === 0) return m;
|
|
451
|
-
let prev = new Array<number>(n + 1);
|
|
452
|
-
let curr = new Array<number>(n + 1);
|
|
453
|
-
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
454
|
-
for (let i = 1; i <= m; i++) {
|
|
455
|
-
curr[0] = i;
|
|
456
|
-
for (let j = 1; j <= n; j++) {
|
|
457
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
458
|
-
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
459
|
-
}
|
|
460
|
-
[prev, curr] = [curr, prev];
|
|
461
|
-
}
|
|
462
|
-
return prev[n];
|
|
463
|
-
}
|
|
464
|
-
|
|
465
564
|
// ============================================
|
|
466
565
|
// Helpers
|
|
467
566
|
// ============================================
|
|
@@ -147,12 +147,66 @@ const QUOTE_PATTERNS = [
|
|
|
147
147
|
/(\w+(?:\s+\w+)?)\s+(?:said|stated|wrote|noted|observed|commented),?\s*"([^"]+)"/gi,
|
|
148
148
|
];
|
|
149
149
|
|
|
150
|
+
// A single side of a comparison ("X" in "from X to Y"). Deliberately excludes
|
|
151
|
+
// sentence/clause terminators (. ! ? ;) and newlines so a capture group can
|
|
152
|
+
// never swallow text from an adjacent sentence. Without this, greedy `.{2,30}`
|
|
153
|
+
// matches across a period — e.g. "…contradicted from witness to witness.
|
|
154
|
+
// Robertson said…" yields right="witness. Robertson", producing a nonsensical
|
|
155
|
+
// "witness → witness. Robertson" slide. Commas and colons are allowed (ranges
|
|
156
|
+
// like "9:00 to 17:00", ratios); downstream label truncation trims commas.
|
|
157
|
+
const COMPARISON_SIDE = '[^.!?;\\r\\n]{2,30}';
|
|
158
|
+
|
|
150
159
|
const COMPARISON_PATTERNS = [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
new RegExp(`from\\s+(${COMPARISON_SIDE})\\s+to\\s+(${COMPARISON_SIDE})`, 'gi'),
|
|
161
|
+
new RegExp(`(${COMPARISON_SIDE})\\s+(?:vs\\.?|versus)\\s+(${COMPARISON_SIDE})`, 'gi'),
|
|
162
|
+
new RegExp(`(${COMPARISON_SIDE})\\s+compared\\s+to\\s+(${COMPARISON_SIDE})`, 'gi'),
|
|
154
163
|
];
|
|
155
164
|
|
|
165
|
+
// Words whose trailing period is an abbreviation, not a sentence end. Used by
|
|
166
|
+
// isSentenceBoundary() so names like "George W. Tibbetts" or "Quong Chong & Co."
|
|
167
|
+
// don't get split into fragments. Matched only when the entire preceding word
|
|
168
|
+
// equals one of these (case-insensitive), so "canal." / "taco." are unaffected.
|
|
169
|
+
const SENTENCE_ABBREVIATIONS = new Set([
|
|
170
|
+
'mr',
|
|
171
|
+
'mrs',
|
|
172
|
+
'ms',
|
|
173
|
+
'dr',
|
|
174
|
+
'prof',
|
|
175
|
+
'st',
|
|
176
|
+
'mt',
|
|
177
|
+
'jr',
|
|
178
|
+
'sr',
|
|
179
|
+
'gen',
|
|
180
|
+
'col',
|
|
181
|
+
'capt',
|
|
182
|
+
'lt',
|
|
183
|
+
'sgt',
|
|
184
|
+
'rev',
|
|
185
|
+
'hon',
|
|
186
|
+
'sen',
|
|
187
|
+
'rep',
|
|
188
|
+
'gov',
|
|
189
|
+
'pres',
|
|
190
|
+
'vs',
|
|
191
|
+
'etc',
|
|
192
|
+
'inc',
|
|
193
|
+
'co',
|
|
194
|
+
'ltd',
|
|
195
|
+
'corp',
|
|
196
|
+
'al',
|
|
197
|
+
'fig',
|
|
198
|
+
'eg',
|
|
199
|
+
'ie',
|
|
200
|
+
'approx',
|
|
201
|
+
'dept',
|
|
202
|
+
'univ',
|
|
203
|
+
'assn',
|
|
204
|
+
'bros',
|
|
205
|
+
'ave',
|
|
206
|
+
'blvd',
|
|
207
|
+
'rd',
|
|
208
|
+
]);
|
|
209
|
+
|
|
156
210
|
const FACT_INDICATORS = [
|
|
157
211
|
'largest',
|
|
158
212
|
'smallest',
|
|
@@ -427,10 +481,38 @@ function extractComparisons(text: string): ExtractedElement[] {
|
|
|
427
481
|
if (seen.has(position)) continue;
|
|
428
482
|
seen.add(position);
|
|
429
483
|
|
|
430
|
-
|
|
431
|
-
|
|
484
|
+
// Trim a trailing clause introduced by a comma + space ("retired
|
|
485
|
+
// airliners, arranged in rows" → "retired airliners"). A greedy side
|
|
486
|
+
// match often swallows the words after the compared thing; the comma is a
|
|
487
|
+
// clause boundary, not part of the value. Numeric grouping commas
|
|
488
|
+
// ("4,000") survive because those are comma+digit, not comma+space.
|
|
489
|
+
const trimClause = (s: string) => s.replace(/,\s+.*$/, '').trim();
|
|
490
|
+
const left = trimClause(match[1].trim());
|
|
491
|
+
const right = trimClause(match[2].trim());
|
|
432
492
|
if (left.length < 2 || left.length > 30) continue;
|
|
433
493
|
if (right.length < 2 || right.length > 30) continue;
|
|
494
|
+
// Reject "from X to X"-style idioms — "from witness to witness",
|
|
495
|
+
// "from day to day", "from generation to generation". These mean "it
|
|
496
|
+
// varied", not "A versus B", and render as a pointless "X → X" card.
|
|
497
|
+
// The signature is a word repeated across the connector: the last word
|
|
498
|
+
// of the left side equals the first word of the right side. (Greedy
|
|
499
|
+
// matching may append trailing context to the right side, so a whole-
|
|
500
|
+
// string equality check is not enough.)
|
|
501
|
+
const wordsOf = (s: string) =>
|
|
502
|
+
s
|
|
503
|
+
.toLowerCase()
|
|
504
|
+
.split(/\s+/)
|
|
505
|
+
.map((w) => w.replace(/[^a-z0-9]+/g, ''))
|
|
506
|
+
.filter(Boolean);
|
|
507
|
+
const leftWords = wordsOf(left);
|
|
508
|
+
const rightWords = wordsOf(right);
|
|
509
|
+
if (
|
|
510
|
+
leftWords.length > 0 &&
|
|
511
|
+
rightWords.length > 0 &&
|
|
512
|
+
leftWords[leftWords.length - 1] === rightWords[0]
|
|
513
|
+
) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
434
516
|
|
|
435
517
|
const context = getSentenceContext(text, position, match[0].length);
|
|
436
518
|
|
|
@@ -447,46 +529,77 @@ function extractComparisons(text: string): ExtractedElement[] {
|
|
|
447
529
|
return elements;
|
|
448
530
|
}
|
|
449
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Whether the `. ! ?` at `index` is a real sentence terminator rather than a
|
|
534
|
+
* period inside an abbreviation ("George W. Tibbetts", "M. DeWitt", "Mr.",
|
|
535
|
+
* "U.S."), a single-letter initial, a decimal ("3.5"), or an ellipsis. Naive
|
|
536
|
+
* splitting on every period produces broken fragments — e.g. the standalone
|
|
537
|
+
* slide "They were met at George W" from "…at George W. Tibbetts' store…".
|
|
538
|
+
* Returns false for any non-terminator character so callers can scan freely.
|
|
539
|
+
*/
|
|
540
|
+
function isSentenceBoundary(text: string, index: number): boolean {
|
|
541
|
+
const ch = text[index];
|
|
542
|
+
if (ch === '!' || ch === '?') return true;
|
|
543
|
+
if (ch !== '.') return false;
|
|
544
|
+
|
|
545
|
+
// Ellipsis: defer the boundary to the final dot in a run of dots.
|
|
546
|
+
if (text[index + 1] === '.') return false;
|
|
547
|
+
// Decimal number: digit "." digit → "3.5".
|
|
548
|
+
if (/[0-9]/.test(text[index - 1] ?? '') && /[0-9]/.test(text[index + 1] ?? '')) return false;
|
|
549
|
+
|
|
550
|
+
// The run of letters immediately preceding the period.
|
|
551
|
+
let w = index - 1;
|
|
552
|
+
while (w >= 0 && /[A-Za-z]/.test(text[w])) w--;
|
|
553
|
+
const word = text.slice(w + 1, index);
|
|
554
|
+
if (word.length === 1) return false; // single-letter initial: "George W."
|
|
555
|
+
if (word && SENTENCE_ABBREVIATIONS.has(word.toLowerCase())) return false;
|
|
556
|
+
|
|
557
|
+
// A genuine sentence starts with an uppercase letter, digit, or opening
|
|
558
|
+
// quote/bracket. A lowercase continuation means the period was mid-sentence
|
|
559
|
+
// (an abbreviation we didn't enumerate).
|
|
560
|
+
let j = index + 1;
|
|
561
|
+
while (j < text.length && /\s/.test(text[j])) j++;
|
|
562
|
+
if (j >= text.length) return true; // end of text
|
|
563
|
+
return !/[a-z]/.test(text[j]);
|
|
564
|
+
}
|
|
565
|
+
|
|
450
566
|
/**
|
|
451
567
|
* Iterate over sentence-like spans in the text, yielding trimmed text and
|
|
452
568
|
* accurate start/end indices within the original string.
|
|
453
569
|
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
570
|
+
* Boundaries are detected by isSentenceBoundary() so abbreviations, initials,
|
|
571
|
+
* decimals, and ellipses do not split a sentence. Leading whitespace and
|
|
572
|
+
* trailing whitespace/terminators are excluded from each span.
|
|
456
573
|
*/
|
|
457
574
|
function forEachSentenceSpan(
|
|
458
575
|
text: string,
|
|
459
576
|
callback: (trimmed: string, start: number, end: number) => void,
|
|
460
577
|
): void {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const rawEnd = rawStart + raw.length;
|
|
468
|
-
|
|
469
|
-
// Compute leading whitespace to exclude
|
|
470
|
-
let leading = 0;
|
|
471
|
-
while (leading < raw.length && /\s/.test(raw[leading])) {
|
|
472
|
-
leading++;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Compute trailing whitespace or sentence-ending punctuation to exclude
|
|
476
|
-
let trailing = 0;
|
|
477
|
-
while (trailing < raw.length - leading && /[\s.!?]/.test(raw[raw.length - 1 - trailing])) {
|
|
478
|
-
trailing++;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const start = rawStart + leading;
|
|
482
|
-
const end = rawEnd - trailing;
|
|
483
|
-
if (end <= start) continue;
|
|
484
|
-
|
|
578
|
+
const emit = (rawStart: number, rawEnd: number) => {
|
|
579
|
+
let start = rawStart;
|
|
580
|
+
while (start < rawEnd && /\s/.test(text[start])) start++;
|
|
581
|
+
let end = rawEnd;
|
|
582
|
+
while (end > start && /[\s.!?]/.test(text[end - 1])) end--;
|
|
583
|
+
if (end <= start) return;
|
|
485
584
|
const trimmed = text.slice(start, end);
|
|
486
|
-
if (
|
|
585
|
+
if (trimmed) callback(trimmed, start, end);
|
|
586
|
+
};
|
|
487
587
|
|
|
488
|
-
|
|
588
|
+
let spanStart = 0;
|
|
589
|
+
let i = 0;
|
|
590
|
+
while (i < text.length) {
|
|
591
|
+
if (isSentenceBoundary(text, i)) {
|
|
592
|
+
// Consume any consecutive terminators ("?!", "…") as one boundary.
|
|
593
|
+
let end = i + 1;
|
|
594
|
+
while (end < text.length && /[.!?]/.test(text[end])) end++;
|
|
595
|
+
emit(spanStart, end);
|
|
596
|
+
spanStart = end;
|
|
597
|
+
i = end;
|
|
598
|
+
} else {
|
|
599
|
+
i++;
|
|
600
|
+
}
|
|
489
601
|
}
|
|
602
|
+
if (spanStart < text.length) emit(spanStart, text.length);
|
|
490
603
|
}
|
|
491
604
|
|
|
492
605
|
function extractFacts(text: string): ExtractedElement[] {
|
|
@@ -621,11 +734,11 @@ function getSentenceContext(
|
|
|
621
734
|
matchLength: number,
|
|
622
735
|
): { sentence: string; start: number; end: number } {
|
|
623
736
|
let start = matchStart;
|
|
624
|
-
while (start > 0 &&
|
|
737
|
+
while (start > 0 && !isSentenceBoundary(text, start - 1)) start--;
|
|
625
738
|
while (start < matchStart && /\s/.test(text[start])) start++;
|
|
626
739
|
|
|
627
740
|
let end = matchStart + matchLength;
|
|
628
|
-
while (end < text.length &&
|
|
741
|
+
while (end < text.length && !isSentenceBoundary(text, end)) end++;
|
|
629
742
|
if (end < text.length) end++;
|
|
630
743
|
|
|
631
744
|
return { sentence: text.slice(start, end).trim(), start, end };
|
package/src/generate/index.ts
CHANGED
|
@@ -16,6 +16,3 @@ export type {
|
|
|
16
16
|
|
|
17
17
|
export { mapElementToBlock } from './templateMapper.js';
|
|
18
18
|
export type { MapOptions } from './templateMapper.js';
|
|
19
|
-
|
|
20
|
-
export { generateSlideshow } from './slideshowGenerator.js';
|
|
21
|
-
export type { SlideshowDoc, SlideshowImage, SlideshowOptions } from './slideshowGenerator.js';
|
package/src/jsonForm/index.ts
CHANGED
|
@@ -20,3 +20,5 @@ export { evaluateWhen, resolveFlag } from './evaluateWhen.js';
|
|
|
20
20
|
export { toPointer, pointerSegments, getByPointer, setByPointer, resolveRef } from './pointer.js';
|
|
21
21
|
export { inferSchema } from './inferSchema.js';
|
|
22
22
|
export type { InferSchemaOptions } from './inferSchema.js';
|
|
23
|
+
export { buildJsonFormTokens, resolveJsonFormTheme } from './tokens.js';
|
|
24
|
+
export type { BuildJsonFormTokensOptions } from './tokens.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure CSS-custom-property builder shared by `<JsonView>` (read-only, in
|
|
3
|
+
* `@bendyline/squisq-react`) and `<JsonEditor>` (editable, in
|
|
4
|
+
* `@bendyline/squisq-editor-react`).
|
|
5
|
+
*
|
|
6
|
+
* Both components derive an identical token bag from a Theme + resolved
|
|
7
|
+
* Surface; they differ only in the CSS-var prefix (`--squisq-json` vs
|
|
8
|
+
* `--squisq-jsonform`) so the viewer and editor can coexist on one page.
|
|
9
|
+
* This function is the single source of that derivation. It is pure and
|
|
10
|
+
* framework-free: it performs no `window` / `matchMedia` access — the
|
|
11
|
+
* surface must already be resolved to a concrete `SurfaceScheme` (or left
|
|
12
|
+
* `undefined` to skip surface application) by the calling React hook.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { applySurface, DEFAULT_THEME, resolveFontFamily } from '../schemas/index.js';
|
|
16
|
+
import type { SurfaceScheme, Theme } from '../schemas/index.js';
|
|
17
|
+
|
|
18
|
+
export interface BuildJsonFormTokensOptions {
|
|
19
|
+
/**
|
|
20
|
+
* CSS-var prefix WITHOUT the trailing dash, e.g. `--squisq-json` or
|
|
21
|
+
* `--squisq-jsonform`. Each token key is `${prefix}-${name}`.
|
|
22
|
+
*/
|
|
23
|
+
prefix: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the CSS custom-property map for a JSON form/view surface.
|
|
28
|
+
*
|
|
29
|
+
* @param theme The theme to render with (falls back to `DEFAULT_THEME`).
|
|
30
|
+
* @param surface Already-resolved concrete surface, or `undefined` to leave
|
|
31
|
+
* the theme un-surfaced. (The `'auto'` → concrete resolution
|
|
32
|
+
* is the React hook's job, so this stays pure.)
|
|
33
|
+
* @param options `{ prefix }` — the CSS-var namespace.
|
|
34
|
+
*/
|
|
35
|
+
export function buildJsonFormTokens(
|
|
36
|
+
theme: Theme | undefined,
|
|
37
|
+
surface: SurfaceScheme | undefined,
|
|
38
|
+
options: BuildJsonFormTokensOptions,
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
const { prefix } = options;
|
|
41
|
+
const baseTheme = theme ?? DEFAULT_THEME;
|
|
42
|
+
const finalTheme = surface ? applySurface(baseTheme, surface) : baseTheme;
|
|
43
|
+
|
|
44
|
+
const titleFont = resolveFontFamily(finalTheme.typography.titleFont, 'system-ui, sans-serif');
|
|
45
|
+
const bodyFont = resolveFontFamily(finalTheme.typography.bodyFont, 'system-ui, sans-serif');
|
|
46
|
+
const monoFont = resolveFontFamily(
|
|
47
|
+
finalTheme.typography.monoFont,
|
|
48
|
+
'ui-monospace, Consolas, monospace',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
[`${prefix}-bg`]: finalTheme.colors.background,
|
|
53
|
+
[`${prefix}-text`]: finalTheme.colors.text,
|
|
54
|
+
[`${prefix}-muted`]: finalTheme.colors.textMuted,
|
|
55
|
+
[`${prefix}-primary`]: finalTheme.colors.primary,
|
|
56
|
+
[`${prefix}-accent`]: finalTheme.colors.secondary,
|
|
57
|
+
[`${prefix}-warning`]: finalTheme.colors.warning,
|
|
58
|
+
[`${prefix}-border`]: `color-mix(in srgb, ${finalTheme.colors.textMuted} 35%, transparent)`,
|
|
59
|
+
[`${prefix}-input-bg`]: finalTheme.colors.backgroundLight,
|
|
60
|
+
[`${prefix}-title-font`]: titleFont,
|
|
61
|
+
[`${prefix}-body-font`]: bodyFont,
|
|
62
|
+
[`${prefix}-mono-font`]: monoFont,
|
|
63
|
+
[`${prefix}-radius`]: `${finalTheme.style.borderRadius ?? 8}px`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the effective theme after surface application — the companion to
|
|
69
|
+
* `buildJsonFormTokens` for callers that also need the `Theme` object (both
|
|
70
|
+
* hooks return it alongside the style bag).
|
|
71
|
+
*/
|
|
72
|
+
export function resolveJsonFormTheme(
|
|
73
|
+
theme: Theme | undefined,
|
|
74
|
+
surface: SurfaceScheme | undefined,
|
|
75
|
+
): Theme {
|
|
76
|
+
const baseTheme = theme ?? DEFAULT_THEME;
|
|
77
|
+
return surface ? applySurface(baseTheme, surface) : baseTheme;
|
|
78
|
+
}
|
|
@@ -205,7 +205,7 @@ export function coerceAnnotationValues(params: Record<string, string>): CoerceRe
|
|
|
205
205
|
// ============================================
|
|
206
206
|
|
|
207
207
|
/** Parse a finite number from a string. Returns null on failure. */
|
|
208
|
-
function parseNumber(raw: string): number | null {
|
|
208
|
+
export function parseNumber(raw: string): number | null {
|
|
209
209
|
if (raw.trim() === '') return null;
|
|
210
210
|
const n = Number(raw);
|
|
211
211
|
return Number.isFinite(n) ? n : null;
|
|
@@ -220,3 +220,22 @@ export function quoteAttrValue(value: string): string {
|
|
|
220
220
|
}
|
|
221
221
|
return `"${value.replace(DQ_ESCAPE_RE, (ch) => `\\${ch}`)}"`;
|
|
222
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Serialize a `{[templateName key=value …]}` template annotation. The inverse
|
|
226
|
+
* of {@link parseStandaloneAnnotation} / the tokenizer: emits the template
|
|
227
|
+
* name (when present) followed by each `key=value` pair in insertion order,
|
|
228
|
+
* quoting values via {@link quoteAttrValue}. Used to re-emit heading-less
|
|
229
|
+
* standalone annotation blocks in `docToMarkdown`.
|
|
230
|
+
*/
|
|
231
|
+
export function serializeAnnotation(
|
|
232
|
+
template: string | undefined,
|
|
233
|
+
params?: Record<string, string>,
|
|
234
|
+
): string {
|
|
235
|
+
const parts: string[] = [];
|
|
236
|
+
if (template) parts.push(template);
|
|
237
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
238
|
+
parts.push(`${key}=${quoteAttrValue(value)}`);
|
|
239
|
+
}
|
|
240
|
+
return `{[${parts.join(' ')}]}`;
|
|
241
|
+
}
|