@bendyline/squisq 1.5.0 → 1.5.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 (124) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BsMkmzH8.d.ts → Doc-BBVGq1_9.d.ts} +94 -15
  4. package/dist/{ImageEditDoc-DvoeHMP9.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +1 -1
  5. package/dist/{chunk-KZNXZ5SM.js → chunk-6ATE2PSM.js} +18 -7
  6. package/dist/chunk-6ATE2PSM.js.map +1 -0
  7. package/dist/{chunk-7MQGNR76.js → chunk-A4O7GIWE.js} +2 -27
  8. package/dist/chunk-A4O7GIWE.js.map +1 -0
  9. package/dist/chunk-ET53IIEP.js +1 -0
  10. package/dist/chunk-ET53IIEP.js.map +1 -0
  11. package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
  12. package/dist/chunk-HWVFJAAH.js.map +1 -0
  13. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  14. package/dist/chunk-IMSCRLLV.js.map +1 -0
  15. package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
  16. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  17. package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
  18. package/dist/{chunk-4ZFRZBU2.js → chunk-LH7I6SH7.js} +777 -151
  19. package/dist/chunk-LH7I6SH7.js.map +1 -0
  20. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  21. package/dist/chunk-T5UK6YOB.js.map +1 -0
  22. package/dist/{chunk-OH43RPTP.js → chunk-TEEEILMP.js} +12 -2
  23. package/dist/chunk-TEEEILMP.js.map +1 -0
  24. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  25. package/dist/chunk-TQWLI6S2.js.map +1 -0
  26. package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
  27. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  28. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  29. package/dist/doc/index.d.ts +4 -4
  30. package/dist/doc/index.js +16 -7
  31. package/dist/generate/index.d.ts +2 -80
  32. package/dist/generate/index.js +2 -7
  33. package/dist/imageEdit/index.d.ts +4 -4
  34. package/dist/index.d.ts +8 -8
  35. package/dist/index.js +32 -23
  36. package/dist/jsonForm/index.d.ts +42 -1
  37. package/dist/jsonForm/index.js +9 -1
  38. package/dist/markdown/index.d.ts +18 -2
  39. package/dist/markdown/index.js +5 -3
  40. package/dist/recommend/index.js +2 -2
  41. package/dist/schemas/index.d.ts +4 -4
  42. package/dist/schemas/index.js +3 -5
  43. package/dist/storage/index.d.ts +2 -2
  44. package/dist/storage/index.js +1 -1
  45. package/dist/story/index.d.ts +125 -11
  46. package/dist/story/index.js +16 -7
  47. package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  48. package/dist/timing/index.js +3 -5
  49. package/dist/transform/index.d.ts +2 -2
  50. package/dist/transform/index.js +3 -3
  51. package/dist/versions/index.d.ts +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/contentExtractor.test.ts +60 -0
  54. package/src/__tests__/coverBlock.test.ts +46 -1
  55. package/src/__tests__/customTemplateSample.test.ts +63 -2
  56. package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
  57. package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
  58. package/src/__tests__/inputDescriptors.test.ts +162 -0
  59. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  60. package/src/__tests__/markdown.test.ts +64 -0
  61. package/src/__tests__/markdownSanitize.test.ts +15 -0
  62. package/src/__tests__/markdownToDoc.test.ts +168 -0
  63. package/src/__tests__/mediaAnnotations.test.ts +27 -0
  64. package/src/__tests__/structuredData.test.ts +73 -3
  65. package/src/__tests__/templateMetadata.test.ts +11 -1
  66. package/src/__tests__/templates.test.ts +112 -0
  67. package/src/__tests__/validateDoc.test.ts +160 -0
  68. package/src/doc/annotationBlocks.ts +117 -0
  69. package/src/doc/customTemplatesFrontmatter.ts +7 -1
  70. package/src/doc/customThemesFrontmatter.ts +6 -1
  71. package/src/doc/docToMarkdown.ts +31 -1
  72. package/src/doc/getLayers.ts +11 -2
  73. package/src/doc/index.ts +12 -0
  74. package/src/doc/markdownToDoc.ts +80 -3
  75. package/src/doc/mediaAnnotations.ts +15 -33
  76. package/src/doc/standaloneAnnotation.ts +58 -0
  77. package/src/doc/structuredData.ts +92 -21
  78. package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
  79. package/src/doc/templates/comparisonBar.ts +8 -1
  80. package/src/doc/templates/coverBlock.ts +29 -2
  81. package/src/doc/templates/customTemplate.ts +10 -4
  82. package/src/doc/templates/factCard.ts +4 -2
  83. package/src/doc/templates/imageWithCaption.ts +10 -8
  84. package/src/doc/templates/index.ts +12 -2
  85. package/src/doc/templates/inputDescriptors.ts +432 -0
  86. package/src/doc/templates/photoGrid.ts +16 -39
  87. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
  88. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  89. package/src/doc/templates/videoWithCaption.ts +15 -14
  90. package/src/doc/utils/nearest.ts +59 -0
  91. package/src/doc/validate.ts +153 -54
  92. package/src/generate/contentExtractor.ts +148 -35
  93. package/src/generate/index.ts +0 -3
  94. package/src/jsonForm/index.ts +2 -0
  95. package/src/jsonForm/tokens.ts +78 -0
  96. package/src/markdown/annotationCoercion.ts +1 -1
  97. package/src/markdown/attrTokens.ts +19 -0
  98. package/src/markdown/index.ts +2 -1
  99. package/src/markdown/sanitize.ts +22 -2
  100. package/src/markdown/stringify.ts +12 -3
  101. package/src/markdown/utils.ts +62 -1
  102. package/src/schemas/CustomTemplates.ts +56 -4
  103. package/src/schemas/Doc.ts +39 -41
  104. package/src/schemas/themeCompile.ts +1 -1
  105. package/src/storage/ContentContainer.ts +6 -2
  106. package/src/transform/types.ts +1 -1
  107. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  108. package/dist/chunk-3E5F2XMR.js.map +0 -1
  109. package/dist/chunk-4AKUQPEE.js.map +0 -1
  110. package/dist/chunk-4X3JQXNM.js +0 -35
  111. package/dist/chunk-4X3JQXNM.js.map +0 -1
  112. package/dist/chunk-4ZFRZBU2.js.map +0 -1
  113. package/dist/chunk-7MQGNR76.js.map +0 -1
  114. package/dist/chunk-FBKP5CAO.js +0 -145
  115. package/dist/chunk-FBKP5CAO.js.map +0 -1
  116. package/dist/chunk-FR2RBTKO.js.map +0 -1
  117. package/dist/chunk-K32VJONL.js.map +0 -1
  118. package/dist/chunk-KZNXZ5SM.js.map +0 -1
  119. package/dist/chunk-OH43RPTP.js.map +0 -1
  120. package/dist/chunk-OKJOQXK4.js.map +0 -1
  121. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  122. package/src/generate/slideshowGenerator.ts +0 -280
  123. /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
  124. /package/dist/{chunk-LRCV7N7F.js.map → chunk-UY7KGQ5R.js.map} +0 -0
@@ -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
- const requested = block.sourceHeading?.templateAnnotation?.template;
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 only recognized on ` +
310
- `headings (or the token is not a known inline icon)`,
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
- /from\s+(.{2,30})\s+to\s+(.{2,30})/gi,
152
- /(.{2,30})\s+(?:vs\.?|versus)\s+(.{2,30})/gi,
153
- /(.{2,30})\s+compared\s+to\s+(.{2,30})/gi,
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
- const left = match[1].trim();
431
- const right = match[2].trim();
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
- * Sentences are approximated as runs of non-[.!?] characters optionally
455
- * followed by [.!?] delimiters. Leading/trailing whitespace is excluded.
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 sentenceRegex = /[^.!?]+[.!?]*/g;
462
- let match: RegExpExecArray | null;
463
-
464
- while ((match = sentenceRegex.exec(text)) !== null) {
465
- const raw = match[0];
466
- const rawStart = match.index;
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 (!trimmed) continue;
585
+ if (trimmed) callback(trimmed, start, end);
586
+ };
487
587
 
488
- callback(trimmed, start, end);
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 && !/[.!?]/.test(text[start - 1])) start--;
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 && !/[.!?]/.test(text[end])) end++;
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 };
@@ -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';
@@ -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
+ }