@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.
Files changed (135) 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-4ZFRZBU2.js → chunk-5TYS5V3G.js} +866 -192
  6. package/dist/chunk-5TYS5V3G.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-OH43RPTP.js → chunk-BEQODJWV.js} +20 -3
  10. package/dist/chunk-BEQODJWV.js.map +1 -0
  11. package/dist/chunk-ET53IIEP.js +1 -0
  12. package/dist/chunk-ET53IIEP.js.map +1 -0
  13. package/dist/{chunk-KZNXZ5SM.js → chunk-GPLTCSXO.js} +18 -7
  14. package/dist/chunk-GPLTCSXO.js.map +1 -0
  15. package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
  16. package/dist/chunk-HWVFJAAH.js.map +1 -0
  17. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  18. package/dist/chunk-IMSCRLLV.js.map +1 -0
  19. package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
  20. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  21. package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
  22. package/dist/{chunk-RUDYOTA6.js → chunk-LTN6I7QW.js} +56 -1
  23. package/dist/chunk-LTN6I7QW.js.map +1 -0
  24. package/dist/{chunk-2OIBZYKP.js → chunk-P3K7NLHF.js} +2 -2
  25. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  26. package/dist/chunk-T5UK6YOB.js.map +1 -0
  27. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  28. package/dist/chunk-TQWLI6S2.js.map +1 -0
  29. package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
  30. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  31. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  32. package/dist/doc/index.d.ts +4 -4
  33. package/dist/doc/index.js +24 -9
  34. package/dist/generate/index.d.ts +2 -80
  35. package/dist/generate/index.js +2 -7
  36. package/dist/imageEdit/index.d.ts +4 -4
  37. package/dist/imageEdit/index.js +2 -2
  38. package/dist/index.d.ts +8 -8
  39. package/dist/index.js +41 -26
  40. package/dist/jsonForm/index.d.ts +42 -1
  41. package/dist/jsonForm/index.js +9 -1
  42. package/dist/markdown/index.d.ts +18 -2
  43. package/dist/markdown/index.js +5 -3
  44. package/dist/recommend/index.js +2 -2
  45. package/dist/schemas/index.d.ts +4 -4
  46. package/dist/schemas/index.js +3 -5
  47. package/dist/storage/index.d.ts +2 -2
  48. package/dist/storage/index.js +1 -1
  49. package/dist/story/index.d.ts +151 -13
  50. package/dist/story/index.js +24 -9
  51. package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  52. package/dist/timing/index.js +3 -5
  53. package/dist/transform/index.d.ts +2 -2
  54. package/dist/transform/index.js +3 -3
  55. package/dist/versions/index.d.ts +1 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/contentExtractor.test.ts +60 -0
  58. package/src/__tests__/coverBlock.test.ts +79 -3
  59. package/src/__tests__/customTemplateSample.test.ts +63 -2
  60. package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
  61. package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
  62. package/src/__tests__/inputDescriptors.test.ts +162 -0
  63. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  64. package/src/__tests__/markdown.test.ts +64 -0
  65. package/src/__tests__/markdownSanitize.test.ts +15 -0
  66. package/src/__tests__/markdownToDoc.test.ts +173 -2
  67. package/src/__tests__/mediaAnnotations.test.ts +27 -0
  68. package/src/__tests__/shapeGeometry.test.ts +37 -0
  69. package/src/__tests__/structuredData.test.ts +73 -3
  70. package/src/__tests__/templateMetadata.test.ts +11 -1
  71. package/src/__tests__/templates.test.ts +112 -0
  72. package/src/__tests__/validateDoc.test.ts +160 -0
  73. package/src/doc/annotationBlocks.ts +117 -0
  74. package/src/doc/customTemplatesFrontmatter.ts +7 -1
  75. package/src/doc/customThemesFrontmatter.ts +6 -1
  76. package/src/doc/docToMarkdown.ts +78 -29
  77. package/src/doc/getLayers.ts +11 -2
  78. package/src/doc/index.ts +12 -0
  79. package/src/doc/markdownToDoc.ts +80 -3
  80. package/src/doc/mediaAnnotations.ts +15 -33
  81. package/src/doc/standaloneAnnotation.ts +58 -0
  82. package/src/doc/structuredData.ts +92 -21
  83. package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
  84. package/src/doc/templates/comparisonBar.ts +8 -1
  85. package/src/doc/templates/coverBlock.ts +78 -15
  86. package/src/doc/templates/customTemplate.ts +10 -4
  87. package/src/doc/templates/diagramBlock.ts +2 -2
  88. package/src/doc/templates/drawingBlock.ts +2 -2
  89. package/src/doc/templates/factCard.ts +4 -2
  90. package/src/doc/templates/imageWithCaption.ts +10 -8
  91. package/src/doc/templates/index.ts +12 -2
  92. package/src/doc/templates/inputDescriptors.ts +432 -0
  93. package/src/doc/templates/photoGrid.ts +16 -39
  94. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
  95. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  96. package/src/doc/templates/videoWithCaption.ts +15 -14
  97. package/src/doc/utils/nearest.ts +59 -0
  98. package/src/doc/utils/shapeGeometry.ts +90 -2
  99. package/src/doc/validate.ts +153 -54
  100. package/src/generate/contentExtractor.ts +148 -35
  101. package/src/generate/index.ts +0 -3
  102. package/src/jsonForm/index.ts +2 -0
  103. package/src/jsonForm/tokens.ts +78 -0
  104. package/src/markdown/annotationCoercion.ts +1 -1
  105. package/src/markdown/attrTokens.ts +19 -0
  106. package/src/markdown/convert.ts +11 -1
  107. package/src/markdown/index.ts +2 -1
  108. package/src/markdown/sanitize.ts +22 -2
  109. package/src/markdown/stringify.ts +12 -3
  110. package/src/markdown/utils.ts +62 -1
  111. package/src/schemas/CustomTemplates.ts +56 -4
  112. package/src/schemas/Doc.ts +39 -41
  113. package/src/schemas/themeCompile.ts +1 -1
  114. package/src/storage/ContentContainer.ts +6 -2
  115. package/src/transform/types.ts +1 -1
  116. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  117. package/dist/chunk-3E5F2XMR.js.map +0 -1
  118. package/dist/chunk-4AKUQPEE.js.map +0 -1
  119. package/dist/chunk-4X3JQXNM.js +0 -35
  120. package/dist/chunk-4X3JQXNM.js.map +0 -1
  121. package/dist/chunk-4ZFRZBU2.js.map +0 -1
  122. package/dist/chunk-7MQGNR76.js.map +0 -1
  123. package/dist/chunk-FBKP5CAO.js +0 -145
  124. package/dist/chunk-FBKP5CAO.js.map +0 -1
  125. package/dist/chunk-FR2RBTKO.js.map +0 -1
  126. package/dist/chunk-K32VJONL.js.map +0 -1
  127. package/dist/chunk-KZNXZ5SM.js.map +0 -1
  128. package/dist/chunk-OH43RPTP.js.map +0 -1
  129. package/dist/chunk-OKJOQXK4.js.map +0 -1
  130. package/dist/chunk-RUDYOTA6.js.map +0 -1
  131. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  132. package/src/generate/slideshowGenerator.ts +0 -280
  133. /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
  134. /package/dist/{chunk-2OIBZYKP.js.map → chunk-P3K7NLHF.js.map} +0 -0
  135. /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();
@@ -18,6 +18,7 @@ import type { Layer } from '../../schemas/Doc.js';
18
18
  import type { TemplateContext } from '../../schemas/BlockTemplates.js';
19
19
  import type { StartBlockConfig } from '../../schemas/Doc.js';
20
20
  import { getThemeFont, themedFontSize, themedImageTreatment } from '../utils/themeUtils.js';
21
+ import { relativeLuminance, withAlpha } from '../../schemas/colorUtils.js';
21
22
  import { mapAmbientMotion } from './accentImage.js';
22
23
 
23
24
  /**
@@ -42,6 +43,30 @@ export interface CoverBlockInput {
42
43
  imageTreatment?: 'none' | 'mono' | 'duotone' | 'warm' | 'cool';
43
44
  }
44
45
 
46
+ /** Base (pre-viewport-scale) font size for a full-size cover title. */
47
+ const COVER_TITLE_BASE_PX = 120;
48
+ /** Title length (chars) that still reads well at the full base size. */
49
+ const COVER_TITLE_COMFORTABLE_CHARS = 26;
50
+ /** Readability floor so very long titles never shrink to nothing. */
51
+ const COVER_TITLE_MIN_BASE_PX = 62;
52
+
53
+ /**
54
+ * Compute the base cover-title font size for a given title, shrinking long
55
+ * titles so their wrapped block fits the frame. Returns the pre-viewport-scale
56
+ * value expected by {@link themedFontSize}; short titles get the full base.
57
+ */
58
+ export function fitCoverTitleSize(title: string): number {
59
+ const len = title.trim().length;
60
+ if (len <= COVER_TITLE_COMFORTABLE_CHARS) return COVER_TITLE_BASE_PX;
61
+ const scaled = COVER_TITLE_BASE_PX * Math.sqrt(COVER_TITLE_COMFORTABLE_CHARS / len);
62
+ return Math.max(COVER_TITLE_MIN_BASE_PX, Math.round(scaled));
63
+ }
64
+
65
+ function isStandardLightCover(context: TemplateContext): boolean {
66
+ const { theme } = context;
67
+ return theme.id === 'standard' && relativeLuminance(theme.colors.background) > 0.85;
68
+ }
69
+
45
70
  /**
46
71
  * Generate cover block layers from StartBlockConfig.
47
72
  */
@@ -49,9 +74,18 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
49
74
  const treatment = themedImageTreatment(context, input.imageTreatment);
50
75
  const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
51
76
  const { theme, layout } = context;
77
+ const standardLightCover = !heroSrc && isStandardLightCover(context);
52
78
 
53
- // Scale font sizes for viewport - cover titles are larger than regular title blocks
54
- const titleFontSize = themedFontSize(120, context, true);
79
+ // Scale font sizes for viewport - cover titles are larger than regular title blocks.
80
+ // The cover title wraps within maxTextWidth but does not otherwise shrink, so a long
81
+ // title (e.g. "Seattle: The Emerald City That Rebuilt Itself on Top of Itself") wrapped
82
+ // to three 120px lines overflows the frame and is clipped by the player controls.
83
+ // Auto-fit the base size to title length so long titles stay within the frame. The
84
+ // wrapped title's total height grows with fontSize² × length (line count ∝ length ×
85
+ // fontSize / width, line height ∝ fontSize), so scaling the base by √(threshold/length)
86
+ // keeps the wrapped block's area roughly constant past the threshold. Short titles are
87
+ // unaffected (scale clamps at 1).
88
+ const titleFontSize = themedFontSize(fitCoverTitleSize(title), context, true);
55
89
  const subtitleFontSize = themedFontSize(40, context, false);
56
90
 
57
91
  const layers: Layer[] = [];
@@ -86,16 +120,42 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
86
120
  },
87
121
  );
88
122
  } else {
89
- // No hero image: use a rich theme-driven background with radial gradient
90
- layers.push({
91
- type: 'shape',
92
- id: 'cover-bg',
93
- content: {
94
- shape: 'rect',
95
- fill: `radial-gradient(ellipse at 50% 40%, ${theme.colors.primary} 0%, ${theme.colors.background} 100%)`,
96
- },
97
- position: { x: 0, y: 0, width: '100%', height: '100%' },
98
- });
123
+ if (standardLightCover) {
124
+ // Standard Light needs a restrained cover-poster treatment; a
125
+ // full-strength primary radial hotspot overwhelms dark serif text
126
+ // and makes the subtitle muddy.
127
+ layers.push(
128
+ {
129
+ type: 'shape',
130
+ id: 'cover-bg',
131
+ content: {
132
+ shape: 'rect',
133
+ fill: theme.colors.background,
134
+ },
135
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
136
+ },
137
+ {
138
+ type: 'shape',
139
+ id: 'cover-bg-tint',
140
+ content: {
141
+ shape: 'rect',
142
+ fill: `radial-gradient(ellipse at 50% 40%, ${withAlpha(theme.colors.primary, 0.16)} 0%, ${withAlpha(theme.colors.primary, 0.07)} 38%, ${withAlpha(theme.colors.primary, 0)} 78%)`,
143
+ },
144
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
145
+ },
146
+ );
147
+ } else {
148
+ // Non-Standard themes keep the existing dramatic radial cover look.
149
+ layers.push({
150
+ type: 'shape',
151
+ id: 'cover-bg',
152
+ content: {
153
+ shape: 'rect',
154
+ fill: `radial-gradient(ellipse at 50% 40%, ${theme.colors.primary} 0%, ${theme.colors.background} 100%)`,
155
+ },
156
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
157
+ });
158
+ }
99
159
 
100
160
  // Subtle decorative accent line below title
101
161
  layers.push({
@@ -103,7 +163,9 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
103
163
  id: 'cover-accent',
104
164
  content: {
105
165
  shape: 'rect',
106
- fill: 'rgba(255, 255, 255, 0.2)',
166
+ fill: standardLightCover
167
+ ? withAlpha(theme.colors.primary, 0.24)
168
+ : 'rgba(255, 255, 255, 0.2)',
107
169
  },
108
170
  position: {
109
171
  x: '35%',
@@ -126,7 +188,7 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
126
188
  fontWeight: 'bold',
127
189
  color: theme.colors.text,
128
190
  textAlign: 'center',
129
- shadow: true,
191
+ shadow: !standardLightCover,
130
192
  },
131
193
  },
132
194
  position: {
@@ -149,7 +211,8 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
149
211
  style: {
150
212
  fontSize: subtitleFontSize,
151
213
  fontFamily: getThemeFont(context, 'body'),
152
- color: theme.colors.textMuted,
214
+ fontWeight: standardLightCover ? 'bold' : 'normal',
215
+ color: standardLightCover ? withAlpha(theme.colors.text, 0.78) : theme.colors.textMuted,
153
216
  textAlign: 'center',
154
217
  lineHeight: 1.5,
155
218
  },
@@ -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
  }