@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
@@ -45,6 +45,8 @@ import { estimateReadingTime } from '../timing/readingTime.js';
45
45
  import { resolveTemplateName, isContainerTemplate } from './templates/index.js';
46
46
  import { isDataFence, parseDataFence, findFirstTable, extractTableData } from './structuredData.js';
47
47
  import { extractMediaFromContents } from './mediaAnnotations.js';
48
+ import { extractTemplateBlocksFromContents } from './annotationBlocks.js';
49
+ import type { ParsedAnnotation } from './standaloneAnnotation.js';
48
50
  import { profileBlockContents, pickAutoTemplate } from '../recommend/templates.js';
49
51
  import { deriveTemplateInputs } from './templateInputs.js';
50
52
  import type { MediaClip } from '../schemas/Media.js';
@@ -145,6 +147,16 @@ function createIdGenerator() {
145
147
  used.set(id, Math.max(used.get(id) ?? 0, 1));
146
148
  };
147
149
 
150
+ // Slug + dedupe from arbitrary text (used for heading-less standalone
151
+ // annotation blocks, which have no MarkdownHeading to slug from). Shares
152
+ // the same `used` map so a standalone id can't collide with a heading id.
153
+ generate.fromText = (text: string): string => {
154
+ const base = slugify(text);
155
+ const count = used.get(base) ?? 0;
156
+ used.set(base, count + 1);
157
+ return count === 0 ? base : `${base}-${count + 1}`;
158
+ };
159
+
148
160
  return generate;
149
161
  }
150
162
 
@@ -196,6 +208,47 @@ function pinnedHeadingMeta(heading: MarkdownHeading | undefined): CoercedBlockMe
196
208
  return { ...fromAnnotation, ...fromPandoc };
197
209
  }
198
210
 
211
+ /**
212
+ * Pinned block-meta for any block: the heading pins ({@link pinnedHeadingMeta})
213
+ * for heading blocks, or the standalone-annotation params for heading-less
214
+ * standalone blocks. Lets a standalone `{[quote duration=8]}` pin timing the
215
+ * same way a heading annotation would.
216
+ */
217
+ function pinnedMeta(block: Block): CoercedBlockMeta {
218
+ if (block.sourceHeading) return pinnedHeadingMeta(block.sourceHeading);
219
+ if (block.sourceAnnotation?.params) {
220
+ return coerceAnnotationValues(block.sourceAnnotation.params).blockMeta;
221
+ }
222
+ return {};
223
+ }
224
+
225
+ /**
226
+ * Rebuild a block list, lifting standalone `{[…]}` template annotations out of
227
+ * each block's body into heading-less sibling blocks (recursing into children
228
+ * first). See {@link extractTemplateBlocksFromContents}.
229
+ */
230
+ function expandStandaloneAnnotations(
231
+ blocks: Block[],
232
+ makeId: (parsed: ParsedAnnotation) => string,
233
+ defaultDuration: number,
234
+ ): Block[] {
235
+ const out: Block[] = [];
236
+ for (const block of blocks) {
237
+ if (block.children && block.children.length > 0) {
238
+ block.children = expandStandaloneAnnotations(block.children, makeId, defaultDuration);
239
+ }
240
+ const { leading, blocks: produced } = extractTemplateBlocksFromContents(block.contents, {
241
+ makeId,
242
+ defaultDuration,
243
+ });
244
+ if (produced.length > 0) {
245
+ block.contents = leading;
246
+ }
247
+ out.push(block, ...produced);
248
+ }
249
+ return out;
250
+ }
251
+
199
252
  export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownToDocOptions): Doc {
200
253
  const articleId = options?.articleId ?? 'markdown-doc';
201
254
  const defaultTemplate = options?.defaultTemplate ?? 'sectionHeader';
@@ -203,7 +256,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
203
256
  const idGenerator = options?.generateId ? null : createIdGenerator();
204
257
  const generateId = options?.generateId ?? idGenerator!;
205
258
 
206
- const rootBlocks: Block[] = [];
259
+ let rootBlocks: Block[] = [];
207
260
  const diagnostics: DocDiagnostic[] = [];
208
261
  let headingIndex = 0;
209
262
 
@@ -375,6 +428,30 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
375
428
  rootBlocks.shift();
376
429
  }
377
430
 
431
+ // Standalone `{[templateName …]}` paragraphs (non-media) become heading-less
432
+ // template blocks; the body after each one, up to the next annotation,
433
+ // becomes its `contents`. Done after media extraction (so media names keep
434
+ // their meaning) and before structured-data parsing (so `json data` fences
435
+ // attach to the produced blocks). Produced blocks are inserted as siblings
436
+ // following the block whose body held the annotation.
437
+ const makeStandaloneId = (parsed: ParsedAnnotation): string => {
438
+ const base = parsed.params.title ?? parsed.template ?? 'block';
439
+ return idGenerator ? idGenerator.fromText(base) : slugify(base);
440
+ };
441
+ rootBlocks = expandStandaloneAnnotations(rootBlocks, makeStandaloneId, defaultDuration);
442
+ // A leading preamble whose entire body was consumed into standalone blocks
443
+ // is now empty — drop it so it doesn't leave a phantom slide.
444
+ if (
445
+ rootBlocks.length > 0 &&
446
+ !rootBlocks[0].sourceHeading &&
447
+ !rootBlocks[0].standaloneAnnotation &&
448
+ (rootBlocks[0].contents?.length ?? 0) === 0 &&
449
+ (rootBlocks[0].children?.length ?? 0) === 0 &&
450
+ !rootBlocks[0].media
451
+ ) {
452
+ rootBlocks.shift();
453
+ }
454
+
378
455
  const allBlocks = flattenBlocks(rootBlocks);
379
456
 
380
457
  // Structured template data: ```json data / ```yaml data fences in a
@@ -435,7 +512,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
435
512
  // Skip blocks that pinned an explicit duration via a heading attribute —
436
513
  // author intent wins over reading-time heuristics.
437
514
  for (const block of allBlocks) {
438
- if (pinnedHeadingMeta(block.sourceHeading).duration != null) continue;
515
+ if (pinnedMeta(block).duration != null) continue;
439
516
  const bodyText = getBlockBodyText(block);
440
517
  if (bodyText.length > 0) {
441
518
  const estimate = estimateReadingTime(bodyText);
@@ -451,7 +528,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
451
528
  // sequence after them.
452
529
  let currentTime = 0;
453
530
  for (const block of allBlocks) {
454
- const explicitStart = pinnedHeadingMeta(block.sourceHeading).startTime;
531
+ const explicitStart = pinnedMeta(block).startTime;
455
532
  if (explicitStart == null) {
456
533
  block.startTime = currentTime;
457
534
  }
@@ -9,47 +9,21 @@
9
9
  * annotations, so quoting/escaping behave identically.
10
10
  */
11
11
 
12
- import {
13
- matchTrailingTemplateAnnotation,
14
- tokenizeAttrTokens,
15
- splitKeyValueToken,
16
- } from '../markdown/attrTokens.js';
17
12
  import { parseTimeSeconds } from '../markdown/annotationCoercion.js';
18
13
  import type { MarkdownBlockNode } from '../markdown/types.js';
19
14
  import type { MediaClip } from '../schemas/Media.js';
15
+ import { parseStandaloneAnnotation, type ParsedAnnotation } from './standaloneAnnotation.js';
20
16
 
21
17
  /** Annotation template names that produce a timed media clip. */
22
18
  const MEDIA_TEMPLATES = new Set(['audio', 'video', 'media']);
23
19
 
24
- interface ParsedAnnotation {
25
- template?: string;
26
- params: Record<string, string>;
27
- }
28
-
29
20
  /**
30
- * Parse a paragraph node that is *exactly* a `{[…]}` annotation (nothing
31
- * before or after). Returns the template + params, or null when the node
32
- * isn't a standalone annotation.
21
+ * Whether a standalone-annotation template name is a media name
22
+ * (`audio`/`video`/`media`). The standalone template-block extractor uses
23
+ * this to leave media annotations to {@link extractMediaFromContents}.
33
24
  */
34
- function parseStandaloneAnnotation(node: MarkdownBlockNode): ParsedAnnotation | null {
35
- if (node.type !== 'paragraph') return null;
36
- const children = node.children ?? [];
37
- if (children.length !== 1 || children[0].type !== 'text') return null;
38
- const text = children[0].value.trim();
39
- const match = matchTrailingTemplateAnnotation(text);
40
- // index 0 means the whole (trimmed) paragraph is the annotation.
41
- if (!match || match.index !== 0) return null;
42
-
43
- const tokens = tokenizeAttrTokens(match.inner.trim());
44
- const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
45
- const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
46
- const startIdx = firstIsParam ? 0 : 1;
47
- const params: Record<string, string> = {};
48
- for (let i = startIdx; i < tokens.length; i++) {
49
- const kv = splitKeyValueToken(tokens[i]);
50
- if (kv) params[kv.key] = kv.value;
51
- }
52
- return { template, params };
25
+ export function isMediaAnnotationName(name: string | undefined): boolean {
26
+ return name != null && MEDIA_TEMPLATES.has(name);
53
27
  }
54
28
 
55
29
  function time(raw: string | undefined): number | null {
@@ -67,7 +41,15 @@ function toMediaClip(parsed: ParsedAnnotation, id: string): MediaClip | null {
67
41
  const kind: MediaClip['kind'] = template === 'video' ? 'video' : 'audio';
68
42
  const anchor: MediaClip['anchor'] =
69
43
  params.anchor === 'document' || params.span === 'document' ? 'document' : 'block';
70
- const clip: MediaClip = { id, src, kind, startAt: time(params.startAt) ?? 0, anchor };
44
+ // `startAt` is canonical; `startTime` is accepted as an alias (it is the
45
+ // heading-attribute spelling, so authors reach for it here too).
46
+ const clip: MediaClip = {
47
+ id,
48
+ src,
49
+ kind,
50
+ startAt: time(params.startAt ?? params.startTime) ?? 0,
51
+ anchor,
52
+ };
71
53
 
72
54
  const clipStart = time(params.clipStart);
73
55
  const clipEnd = time(params.clipEnd);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Standalone-paragraph annotation parser.
3
+ *
4
+ * A paragraph whose entire trimmed text is exactly a `{[…]}` annotation
5
+ * (nothing before or after) is a *standalone annotation*. Two features share
6
+ * this shape:
7
+ *
8
+ * - **Media annotations** (`{[audio …]}` / `{[video …]}`) — lifted into typed
9
+ * {@link import('../schemas/Media.js').MediaClip}s by `mediaAnnotations.ts`.
10
+ * - **Standalone template blocks** (`{[quote]}`, `{[statHighlight …]}`, …) —
11
+ * turned into heading-less {@link import('../schemas/Doc.js').Block}s by
12
+ * `annotationBlocks.ts`.
13
+ *
14
+ * Both call the single parser here so the annotation grammar (quoting,
15
+ * escaping, first-token-is-template) stays identical to the heading form.
16
+ */
17
+
18
+ import {
19
+ matchTrailingTemplateAnnotation,
20
+ tokenizeAttrTokens,
21
+ splitKeyValueToken,
22
+ } from '../markdown/attrTokens.js';
23
+ import type { MarkdownBlockNode } from '../markdown/types.js';
24
+
25
+ /** The template name + params parsed out of a standalone `{[…]}` paragraph. */
26
+ export interface ParsedAnnotation {
27
+ /** First bare token, when the annotation leads with one (e.g. `quote`). */
28
+ template?: string;
29
+ /** Remaining `key=value` tokens, unquoted/unescaped. */
30
+ params: Record<string, string>;
31
+ }
32
+
33
+ /**
34
+ * Parse a paragraph node that is *exactly* a `{[…]}` annotation (nothing
35
+ * before or after). Returns the template + params, or null when the node
36
+ * isn't a standalone annotation (not a paragraph, more than one child, a
37
+ * non-text child such as a resolved inline icon, or trailing text).
38
+ */
39
+ export function parseStandaloneAnnotation(node: MarkdownBlockNode): ParsedAnnotation | null {
40
+ if (node.type !== 'paragraph') return null;
41
+ const children = node.children ?? [];
42
+ if (children.length !== 1 || children[0].type !== 'text') return null;
43
+ const text = children[0].value.trim();
44
+ const match = matchTrailingTemplateAnnotation(text);
45
+ // index 0 means the whole (trimmed) paragraph is the annotation.
46
+ if (!match || match.index !== 0) return null;
47
+
48
+ const tokens = tokenizeAttrTokens(match.inner.trim());
49
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
50
+ const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
51
+ const startIdx = firstIsParam ? 0 : 1;
52
+ const params: Record<string, string> = {};
53
+ for (let i = startIdx; i < tokens.length; i++) {
54
+ const kv = splitKeyValueToken(tokens[i]);
55
+ if (kv) params[kv.key] = kv.value;
56
+ }
57
+ return { template, params };
58
+ }
@@ -27,10 +27,12 @@
27
27
  * didn't provide them explicitly.
28
28
  *
29
29
  * YAML support is a documented subset (this package has no YAML
30
- * dependency): top-level `key: scalar`, `key: [inline, array]`, and
31
- * `key:` followed by an indented `- item` block sequence whose items are
32
- * scalars or inline arrays. Nested maps are rejected with a clear error —
33
- * use a `json data` fence for deeply nested input.
30
+ * dependency): top-level `key: scalar`, `key: [inline, array]`, `key:`
31
+ * followed by an indented `- item` block sequence (scalars or inline
32
+ * arrays), and `key:` followed by *one level* of indented `subkey: scalar`
33
+ * lines (a nested map, e.g. `center:` + indented `lat:` / `lng:`). Deeper
34
+ * nesting, and mixing list items with mapping keys under one key, are
35
+ * rejected with a clear error — use a `json data` fence for those.
34
36
  */
35
37
 
36
38
  import type {
@@ -79,17 +81,42 @@ export function parseDataFence(node: MarkdownCodeBlock): DataFenceParseResult {
79
81
  return { error: 'JSON data fence must contain a top-level object ({"key": …})' };
80
82
  }
81
83
  return { data: parsed as Record<string, unknown> };
82
- } catch (err) {
84
+ } catch (err: unknown) {
83
85
  return { error: `invalid JSON: ${err instanceof Error ? err.message : String(err)}` };
84
86
  }
85
87
  }
86
88
  try {
87
89
  return { data: parseYamlSubset(node.value) };
88
- } catch (err) {
90
+ } catch (err: unknown) {
89
91
  return { error: `invalid YAML: ${err instanceof Error ? err.message : String(err)}` };
90
92
  }
91
93
  }
92
94
 
95
+ /**
96
+ * Return a new contents array with the first data fence's code content
97
+ * replaced by `data` (pretty-printed JSON). The fence node keeps its
98
+ * language and `data` marker; only `value` changes. When no data fence
99
+ * exists, a new ` ```json data ` fence is appended instead.
100
+ *
101
+ * Pure: neither `contents` nor its nodes are mutated. This is the write
102
+ * half of the data-fence channel — editors update `block.templateData`
103
+ * and call this to reflect the change back into `block.contents`.
104
+ */
105
+ export function replaceDataFence(
106
+ contents: MarkdownBlockNode[],
107
+ data: Record<string, unknown>,
108
+ ): MarkdownBlockNode[] {
109
+ const value = JSON.stringify(data, null, 2);
110
+ const index = contents.findIndex((node) => node.type === 'code' && isDataFence(node));
111
+ if (index < 0) {
112
+ const fence: MarkdownCodeBlock = { type: 'code', lang: 'json', meta: 'data', value };
113
+ return [...contents, fence];
114
+ }
115
+ const next = [...contents];
116
+ next[index] = { ...(contents[index] as MarkdownCodeBlock), value };
117
+ return next;
118
+ }
119
+
93
120
  // ============================================
94
121
  // YAML subset parser
95
122
  // ============================================
@@ -153,30 +180,72 @@ function parseInlineValue(raw: string): unknown {
153
180
  export function parseYamlSubset(src: string): Record<string, unknown> {
154
181
  const result: Record<string, unknown> = {};
155
182
  const lines = src.split('\n');
156
- let pendingListKey: string | null = null;
183
+
184
+ // Open block under the most recent `key:` line: whether its children are a
185
+ // block sequence (`- …`) or a nested map (`subkey: …`) is decided by the
186
+ // first indented child; `childIndent` pins the one allowed child depth.
187
+ let pendingKey: string | null = null;
188
+ let pendingKind: 'list' | 'map' | null = null;
189
+ let childIndent = -1;
157
190
 
158
191
  for (let i = 0; i < lines.length; i++) {
159
192
  const line = lines[i];
160
193
  const trimmed = line.trim();
161
194
  if (!trimmed || trimmed.startsWith('#')) continue;
162
195
 
163
- const indented = /^\s/.test(line);
196
+ const indent = line.length - line.trimStart().length;
164
197
 
165
- if (indented && trimmed.startsWith('- ')) {
166
- if (!pendingListKey) {
167
- throw new Error(`line ${i + 1}: list item without a preceding "key:" line`);
198
+ if (indent > 0) {
199
+ if (!pendingKey) {
200
+ throw new Error(`line ${i + 1}: unexpected indented line without a preceding "key:" line`);
201
+ }
202
+ if (childIndent < 0) childIndent = indent;
203
+ if (indent > childIndent) {
204
+ throw new Error(
205
+ `line ${i + 1}: nesting deeper than one level is not supported — use a \`\`\`json data fence for nested input`,
206
+ );
207
+ }
208
+
209
+ if (trimmed.startsWith('- ')) {
210
+ if (pendingKind === 'map') {
211
+ throw new Error(
212
+ `line ${i + 1}: cannot mix list items and mapping keys under "${pendingKey}"`,
213
+ );
214
+ }
215
+ pendingKind = 'list';
216
+ (result[pendingKey] as unknown[]).push(parseInlineValue(trimmed.slice(2)));
217
+ continue;
168
218
  }
169
- (result[pendingListKey] as unknown[]).push(parseInlineValue(trimmed.slice(2)));
170
- continue;
171
- }
172
219
 
173
- if (indented) {
174
- throw new Error(
175
- `line ${i + 1}: nested mappings are not supported — use a \`\`\`json data fence for nested input`,
176
- );
220
+ // Otherwise this is a nested `subkey: value` mapping line.
221
+ if (pendingKind === 'list') {
222
+ throw new Error(
223
+ `line ${i + 1}: cannot mix mapping keys and list items under "${pendingKey}"`,
224
+ );
225
+ }
226
+ const subColon = trimmed.indexOf(':');
227
+ if (subColon < 1) {
228
+ throw new Error(`line ${i + 1}: expected "key: value"`);
229
+ }
230
+ const subValue = trimmed.slice(subColon + 1).trim();
231
+ if (subValue === '') {
232
+ // A nested `subkey:` opening its own block would be a second level.
233
+ throw new Error(
234
+ `line ${i + 1}: nesting deeper than one level is not supported — use a \`\`\`json data fence for nested input`,
235
+ );
236
+ }
237
+ if (pendingKind !== 'map') {
238
+ pendingKind = 'map';
239
+ result[pendingKey] = {};
240
+ }
241
+ (result[pendingKey] as Record<string, unknown>)[trimmed.slice(0, subColon).trim()] =
242
+ parseInlineValue(subValue);
243
+ continue;
177
244
  }
178
245
 
179
- pendingListKey = null;
246
+ pendingKey = null;
247
+ pendingKind = null;
248
+ childIndent = -1;
180
249
  const colonIdx = trimmed.indexOf(':');
181
250
  if (colonIdx < 1) {
182
251
  throw new Error(`line ${i + 1}: expected "key: value"`);
@@ -185,9 +254,11 @@ export function parseYamlSubset(src: string): Record<string, unknown> {
185
254
  const rawValue = trimmed.slice(colonIdx + 1).trim();
186
255
 
187
256
  if (rawValue === '') {
188
- // `key:` opens a block sequence; items follow as indented `- …` lines.
257
+ // `key:` opens a block: an indented `- …` sequence or a `subkey: …`
258
+ // map. Default to an empty list so a `key:` with no children stays
259
+ // back-compatible (`{ key: [] }`); the map branch replaces it wholesale.
189
260
  result[key] = [];
190
- pendingListKey = key;
261
+ pendingKey = key;
191
262
  } else {
192
263
  result[key] = parseInlineValue(rawValue);
193
264
  }
@@ -70,7 +70,7 @@ describe('makeCustomTemplateFn', () => {
70
70
  expect((layers[0] as TextLayer).content.text).toBe('Hello, World!');
71
71
  });
72
72
 
73
- it('returns layers unchanged when context.block is missing', () => {
73
+ it('falls back to the input block when context.block is missing', () => {
74
74
  const fn = makeCustomTemplateFn(
75
75
  defn([
76
76
  {
@@ -82,8 +82,27 @@ describe('makeCustomTemplateFn', () => {
82
82
  ]),
83
83
  );
84
84
  const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
85
+ // No ctx.block set; the fn resolves against `input` instead. The
86
+ // default input carries no title, so {title} collapses to empty.
85
87
  const layers = fn(makeInput(), ctx);
86
- expect((layers[0] as TextLayer).content.text).toBe('{title}');
88
+ expect((layers[0] as TextLayer).content.text).toBe('');
89
+ });
90
+
91
+ it('resolves {attr:key} from the input block when context.block is missing', () => {
92
+ const fn = makeCustomTemplateFn(
93
+ defn([
94
+ {
95
+ id: 't',
96
+ type: 'text',
97
+ position: { x: '0%', y: '0%', width: '100%', height: '10%' },
98
+ content: { text: '{attr:role}', style: { fontSize: 24, color: '#000' } },
99
+ },
100
+ ]),
101
+ );
102
+ const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
103
+ const input = { ...makeInput(), templateOverrides: { role: 'lead' } } as RawLayersInput;
104
+ const layers = fn(input, ctx);
105
+ expect((layers[0] as TextLayer).content.text).toBe('lead');
87
106
  });
88
107
 
89
108
  it('preserves %-based positions verbatim — they resolve at SSR render time', () => {
@@ -18,15 +18,22 @@ import {
18
18
  } from '../utils/themeUtils.js';
19
19
 
20
20
  export function comparisonBar(input: ComparisonBarInput, context: TemplateContext): Layer[] {
21
- const { leftLabel, leftValue, rightLabel, rightValue, unit, colorScheme = 'blue' } = input;
21
+ const { leftLabel, rightLabel, unit, colorScheme = 'blue' } = input;
22
22
  const { theme, viewport } = context;
23
23
  const colors = resolveColorScheme(context, colorScheme);
24
24
 
25
+ // Defensive: coerce non-finite values to 0. Callers should pass real numbers,
26
+ // but a NaN would otherwise render as literal "NaN" text and collapse every
27
+ // bar/label to a `NaN%` position, cramming the block against its left edge.
28
+ const leftValue = Number.isFinite(input.leftValue) ? input.leftValue : 0;
29
+ const rightValue = Number.isFinite(input.rightValue) ? input.rightValue : 0;
30
+
25
31
  const labelFontSize = themedFontSize(28, context, false);
26
32
  const valueFontSize = themedFontSize(48, context, true);
27
33
 
28
34
  // Format values for display
29
35
  const formatValue = (v: number): string => {
36
+ if (!Number.isFinite(v)) return '—';
30
37
  if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
31
38
  if (v >= 1_000) return `${(v / 1_000).toFixed(v >= 10_000 ? 0 : 1)}K`;
32
39
  return v.toLocaleString();
@@ -42,6 +42,25 @@ export interface CoverBlockInput {
42
42
  imageTreatment?: 'none' | 'mono' | 'duotone' | 'warm' | 'cool';
43
43
  }
44
44
 
45
+ /** Base (pre-viewport-scale) font size for a full-size cover title. */
46
+ const COVER_TITLE_BASE_PX = 120;
47
+ /** Title length (chars) that still reads well at the full base size. */
48
+ const COVER_TITLE_COMFORTABLE_CHARS = 26;
49
+ /** Readability floor so very long titles never shrink to nothing. */
50
+ const COVER_TITLE_MIN_BASE_PX = 62;
51
+
52
+ /**
53
+ * Compute the base cover-title font size for a given title, shrinking long
54
+ * titles so their wrapped block fits the frame. Returns the pre-viewport-scale
55
+ * value expected by {@link themedFontSize}; short titles get the full base.
56
+ */
57
+ export function fitCoverTitleSize(title: string): number {
58
+ const len = title.trim().length;
59
+ if (len <= COVER_TITLE_COMFORTABLE_CHARS) return COVER_TITLE_BASE_PX;
60
+ const scaled = COVER_TITLE_BASE_PX * Math.sqrt(COVER_TITLE_COMFORTABLE_CHARS / len);
61
+ return Math.max(COVER_TITLE_MIN_BASE_PX, Math.round(scaled));
62
+ }
63
+
45
64
  /**
46
65
  * Generate cover block layers from StartBlockConfig.
47
66
  */
@@ -50,8 +69,16 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
50
69
  const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
51
70
  const { theme, layout } = context;
52
71
 
53
- // Scale font sizes for viewport - cover titles are larger than regular title blocks
54
- const titleFontSize = themedFontSize(120, context, true);
72
+ // Scale font sizes for viewport - cover titles are larger than regular title blocks.
73
+ // The cover title wraps within maxTextWidth but does not otherwise shrink, so a long
74
+ // title (e.g. "Seattle: The Emerald City That Rebuilt Itself on Top of Itself") wrapped
75
+ // to three 120px lines overflows the frame and is clipped by the player controls.
76
+ // Auto-fit the base size to title length so long titles stay within the frame. The
77
+ // wrapped title's total height grows with fontSize² × length (line count ∝ length ×
78
+ // fontSize / width, line height ∝ fontSize), so scaling the base by √(threshold/length)
79
+ // keeps the wrapped block's area roughly constant past the threshold. Short titles are
80
+ // unaffected (scale clamps at 1).
81
+ const titleFontSize = themedFontSize(fitCoverTitleSize(title), context, true);
55
82
  const subtitleFontSize = themedFontSize(40, context, false);
56
83
 
57
84
  const layers: Layer[] = [];
@@ -12,7 +12,7 @@
12
12
  * UI and the resolver, not here.
13
13
  */
14
14
 
15
- import type { Layer } from '../../schemas/Doc.js';
15
+ import type { Block, Layer } from '../../schemas/Doc.js';
16
16
  import type {
17
17
  RawLayersInput,
18
18
  TemplateContext,
@@ -38,8 +38,14 @@ import { resolveTokens } from './tokens/resolveTokens.js';
38
38
  export function makeCustomTemplateFn(
39
39
  def: CustomTemplateDefinition,
40
40
  ): TemplateFunction<RawLayersInput> {
41
- return (_input: RawLayersInput, context: TemplateContext): Layer[] => {
42
- if (!context.block) return def.layers.slice();
43
- return resolveTokens(def.layers, context.block);
41
+ return (input: RawLayersInput, context: TemplateContext): Layer[] => {
42
+ // `{attr:…}` reads the source block's `templateOverrides` /
43
+ // `templateData` / `metadata`. Prefer `context.block` (the raw
44
+ // block the expander attaches); fall back to `input` (the merged
45
+ // TemplateBlock, itself a Block superset) so attributes still
46
+ // resolve if `context.block` is ever absent.
47
+ const block = context.block ?? (input as unknown as Block | undefined);
48
+ if (!block) return def.layers.slice() as Layer[];
49
+ return resolveTokens(def.layers, block);
44
50
  };
45
51
  }
@@ -32,9 +32,11 @@ export function factCard(input: FactCardInput, context: TemplateContext): Layer[
32
32
  // Get layout adjustments if accent image is present
33
33
  const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
34
34
 
35
- // Scale font sizes for viewport
35
+ // Scale font sizes for viewport. The explanation sits at ~60% of the fact
36
+ // size — large enough to read comfortably as the secondary line while still
37
+ // clearly subordinate to the headline fact.
36
38
  const factFontSize = themedFontSize(56, context, true);
37
- const explainFontSize = themedFontSize(28, context, false);
39
+ const explainFontSize = themedFontSize(34, context, false);
38
40
  const sourceFontSize = themedFontSize(20, context, false);
39
41
 
40
42
  const layers: Layer[] = [createBackgroundLayer('bg', themedSurfaceGradient(context, 170))];
@@ -38,7 +38,7 @@ export function imageWithCaption(input: ImageWithCaptionInput, context: Template
38
38
  const treatment = themedImageTreatment(context, input.imageTreatment);
39
39
 
40
40
  // Scale font sizes for viewport
41
- const captionFontSize = themedFontSize(36, context, false);
41
+ const captionFontSize = themedFontSize(30, context, false);
42
42
  const titleFontSize = themedFontSize(96, context, true);
43
43
  const subtitleFontSize = themedFontSize(36, context, false);
44
44
 
@@ -140,23 +140,23 @@ export function imageWithCaption(input: ImageWithCaptionInput, context: Template
140
140
 
141
141
  // STANDARD MODE: Caption at bottom
142
142
  if (caption) {
143
- // Subtle gradient at bottom for caption readability (raised above media controls)
143
+ // Compact caption band raised above media controls. Long archival captions
144
+ // are clamped so they don't sprawl across the image.
144
145
  layers.push({
145
146
  type: 'shape',
146
147
  id: 'caption-gradient',
147
148
  content: {
148
149
  shape: 'rect',
149
- fill: `linear-gradient(0deg, ${withAlpha(bg, 0.8)} 0%, ${withAlpha(bg, 0.35)} 60%, ${withAlpha(bg, 0)} 100%)`,
150
+ fill: `linear-gradient(90deg, ${withAlpha(bg, 0)}, ${withAlpha(bg, 0.78)} 16%, ${withAlpha(bg, 0.78)} 84%, ${withAlpha(bg, 0)})`,
150
151
  },
151
152
  position: {
152
153
  x: 0,
153
- y: '65%',
154
+ y: '68%',
154
155
  width: '100%',
155
- height: '35%',
156
+ height: '16%',
156
157
  },
157
158
  });
158
159
 
159
- // Caption text - raised above media controls
160
160
  layers.push({
161
161
  type: 'text',
162
162
  id: 'caption',
@@ -168,13 +168,15 @@ export function imageWithCaption(input: ImageWithCaptionInput, context: Template
168
168
  color: theme.colors.text,
169
169
  textAlign: 'center',
170
170
  shadow: shouldUseShadow(context),
171
+ lineHeight: 1.18,
172
+ maxLines: 2,
171
173
  },
172
174
  },
173
175
  position: {
174
176
  x: '50%',
175
- y: '82%',
177
+ y: '74%',
176
178
  anchor: 'center',
177
- width: layout.maxTextWidth,
179
+ width: '78%',
178
180
  },
179
181
  animation: { type: 'fadeIn', duration: 1.5, delay: 0.5 },
180
182
  });
@@ -27,6 +27,10 @@ import { expandPersistentLayers, wrapWithPersistentLayers } from './persistentLa
27
27
  import { applyRenderStyleToLayers } from '../utils/applyRenderStyle.js';
28
28
  import { resolveBlockTransition } from '../../schemas/Transitions.js';
29
29
  import { makeCustomTemplateFn } from './customTemplate.js';
30
+ // Coerces inline `{[…]}` string params into typed template input. inputDescriptors
31
+ // imports `resolveTemplateName` from this module in return; the cycle is benign
32
+ // because every cross-reference lives inside a function body, not module init.
33
+ import { coerceTemplateParams } from './inputDescriptors.js';
30
34
  import type { ViewportConfig } from '../../schemas/Viewport.js';
31
35
  import { VIEWPORT_PRESETS } from '../../schemas/Viewport.js';
32
36
 
@@ -197,11 +201,17 @@ export function expandTemplateBlock(
197
201
 
198
202
  // Effective template input: the block's own fields, then structured
199
203
  // body data (```json data fences / derived auto-template inputs), then
200
- // `{[…]}` string overrides — the same merge order getLayers uses.
204
+ // `{[…]}` string overrides — the same merge order getLayers uses. Inline
205
+ // overrides are coerced to their typed shape (e.g. `center="47.6,-122.3"` →
206
+ // `{lat,lng}`); `templateOverrides` itself stays raw for round-tripping.
201
207
  const { templateData, templateOverrides } = templateBlock as Block;
202
208
  const input =
203
209
  templateData || templateOverrides
204
- ? ({ ...templateBlock, ...templateData, ...templateOverrides } as TemplateBlock)
210
+ ? ({
211
+ ...templateBlock,
212
+ ...templateData,
213
+ ...coerceTemplateParams(templateBlock.template, templateOverrides ?? {}).input,
214
+ } as TemplateBlock)
205
215
  : templateBlock;
206
216
 
207
217
  // Generate layers from template with error handling