@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
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { templateRegistry, TEMPLATE_METADATA } from '../doc/templates/index';
2
+ import { templateRegistry, TEMPLATE_METADATA, TEMPLATE_ALIASES } from '../doc/templates/index';
3
3
 
4
4
  describe('TEMPLATE_METADATA', () => {
5
5
  it('stays exactly 1:1 with templateRegistry (same ids)', () => {
@@ -19,3 +19,13 @@ describe('TEMPLATE_METADATA', () => {
19
19
  }
20
20
  });
21
21
  });
22
+
23
+ describe('TEMPLATE_ALIASES', () => {
24
+ it('every alias target resolves to a real registry entry', () => {
25
+ // A typo'd alias target would otherwise fall through resolveTemplateName's
26
+ // `?? name` fallback and silently resolve to a non-existent template.
27
+ for (const [alias, target] of Object.entries(TEMPLATE_ALIASES)) {
28
+ expect(target in templateRegistry, `alias "${alias}" → "${target}"`).toBe(true);
29
+ }
30
+ });
31
+ });
@@ -10,6 +10,10 @@ import {
10
10
  VIEWPORT_PRESETS,
11
11
  } from '../doc/templates/index';
12
12
  import type { TemplateBlock } from '../schemas/BlockTemplates';
13
+ import type { TextLayer } from '../schemas/Doc';
14
+ import { getLayers } from '../doc/getLayers';
15
+ import { markdownToDoc } from '../doc/markdownToDoc';
16
+ import { parseMarkdown } from '../markdown/parse';
13
17
 
14
18
  describe('templateRegistry', () => {
15
19
  it('contains all expected templates', () => {
@@ -244,6 +248,75 @@ describe('expandTemplateBlock', () => {
244
248
  expect((result.layers ?? []).length).toBeGreaterThanOrEqual(1);
245
249
  }
246
250
  });
251
+
252
+ it('keeps photoGrid media captions compact without placeholder alt labels', () => {
253
+ const block: TemplateBlock = {
254
+ template: 'photoGrid',
255
+ id: 'photo-grid-captions',
256
+ duration: 10,
257
+ audioSegment: 0,
258
+ images: [
259
+ { src: 'one.jpg', alt: 'A long archival caption for the first image' },
260
+ { src: 'two.jpg', alt: 'A long archival caption for the second image' },
261
+ { src: 'three.jpg', alt: 'A long archival caption for the third image' },
262
+ ],
263
+ caption:
264
+ 'A long Wikimedia description with source notes, place names, dates, and details that should remain compact.',
265
+ };
266
+
267
+ const context = createTemplateContext(DEFAULT_THEME, 0, 10, VIEWPORT_PRESETS.landscape);
268
+ const result = expandTemplateBlock(block, context);
269
+ const layers = result.layers ?? [];
270
+ const captionLayer = layers.find(
271
+ (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
272
+ );
273
+
274
+ expect(layers.some((layer) => layer.id.startsWith('grid-alt-'))).toBe(false);
275
+ expect(captionLayer).toBeDefined();
276
+ expect(captionLayer!.content.style.maxLines).toBe(1);
277
+ expect(captionLayer!.position.width).toBe('78%');
278
+ });
279
+
280
+ it('clamps full-bleed media captions above playback controls', () => {
281
+ const imageBlock: TemplateBlock = {
282
+ template: 'imageWithCaption',
283
+ id: 'image-caption',
284
+ duration: 10,
285
+ audioSegment: 0,
286
+ imageSrc: 'image.jpg',
287
+ imageAlt: 'Image',
288
+ caption:
289
+ 'A long Wikimedia description with source notes, place names, dates, and details that should remain compact.',
290
+ };
291
+ const videoBlock: TemplateBlock = {
292
+ template: 'videoWithCaption',
293
+ id: 'video-caption',
294
+ duration: 10,
295
+ audioSegment: 0,
296
+ videoSrc: 'video.mp4',
297
+ videoAlt: 'Video',
298
+ clipStart: 0,
299
+ clipEnd: 10,
300
+ caption: imageBlock.caption,
301
+ };
302
+
303
+ const context = createTemplateContext(DEFAULT_THEME, 0, 10, VIEWPORT_PRESETS.landscape);
304
+ const imageCaption = (expandTemplateBlock(imageBlock, context).layers ?? []).find(
305
+ (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
306
+ );
307
+ const videoCaption = (expandTemplateBlock(videoBlock, context).layers ?? []).find(
308
+ (layer): layer is TextLayer => layer.type === 'text' && layer.id === 'caption',
309
+ );
310
+
311
+ expect(imageCaption).toBeDefined();
312
+ expect(videoCaption).toBeDefined();
313
+ expect(imageCaption!.content.style.maxLines).toBe(2);
314
+ expect(videoCaption!.content.style.maxLines).toBe(2);
315
+ expect(imageCaption!.position.y).toBe('74%');
316
+ expect(videoCaption!.position.y).toBe('74%');
317
+ expect(imageCaption!.position.width).toBe('78%');
318
+ expect(videoCaption!.position.width).toBe('78%');
319
+ });
247
320
  });
248
321
 
249
322
  describe('expandDocBlocks', () => {
@@ -460,6 +533,45 @@ describe('DEFAULT_THEME', () => {
460
533
  });
461
534
  });
462
535
 
536
+ describe('inline-param coercion → getLayers', () => {
537
+ function firstBlock(md: string) {
538
+ return markdownToDoc(parseMarkdown(md), { generateCoverBlock: false }).blocks[0];
539
+ }
540
+
541
+ it('renders a map from pure-inline {[map center=… zoom=…]} annotation', () => {
542
+ const block = firstBlock('# Downtown {[map center="47.6,-122.3" zoom=9 mapStyle="road"]}\n');
543
+ // Raw overrides stay strings for lossless round-trip.
544
+ expect(block.templateOverrides).toEqual({
545
+ center: '47.6,-122.3',
546
+ zoom: '9',
547
+ mapStyle: 'road',
548
+ });
549
+
550
+ const layers = getLayers(block, {});
551
+ const mapLayer = layers.find((l) => l.type === 'map');
552
+ expect(mapLayer).toBeDefined();
553
+ // Coerced center/zoom flow into the map layer's content — not empty strings.
554
+ const content = mapLayer!.content as { center: { lat: number; lng: number }; zoom: number };
555
+ expect(content.center).toEqual({ lat: 47.6, lng: -122.3 });
556
+ expect(content.zoom).toBe(9);
557
+ });
558
+
559
+ it('renders a twoColumn from pure-inline labeled-pair annotation', () => {
560
+ const block = firstBlock('# Coffee {[twoColumn left="Espresso|Bold" right="Filter|Smooth"]}\n');
561
+ const layers = getLayers(block, {});
562
+ // Guard requires both labels — a non-empty layer set proves coercion worked.
563
+ expect(layers.length).toBeGreaterThan(0);
564
+ const texts = layers
565
+ .filter((l) => l.type === 'text')
566
+ .map((l) => (l.content as { text: string }).text);
567
+ expect(texts).toContain('Espresso');
568
+ expect(texts).toContain('Filter');
569
+ // The sublabels from the "|" split render too.
570
+ expect(texts).toContain('Bold');
571
+ expect(texts).toContain('Smooth');
572
+ });
573
+ });
574
+
463
575
  describe('VIEWPORT_PRESETS', () => {
464
576
  it('has landscape and portrait presets', () => {
465
577
  expect(VIEWPORT_PRESETS).toHaveProperty('landscape');
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { validateMarkdownSource } from '../doc/validate.js';
3
+ import { writeCustomTemplatesToFrontmatter } from '../doc/customTemplatesFrontmatter';
4
+ import type { CustomTemplateDefinition } from '../schemas/CustomTemplates.js';
3
5
 
4
6
  function codes(md: string, options?: Parameters<typeof validateMarkdownSource>[1]): string[] {
5
7
  return validateMarkdownSource(md, options).diagnostics.map((d) => d.code);
@@ -11,6 +13,7 @@ describe('validateMarkdownSource — templates', () => {
11
13
  expect(result.diagnostics).toEqual([]);
12
14
  expect(result.errorCount).toBe(0);
13
15
  expect(result.warningCount).toBe(0);
16
+ expect(result.infoCount).toBe(0);
14
17
  });
15
18
 
16
19
  it('flags unknown templates with a did-you-mean suggestion', () => {
@@ -71,6 +74,43 @@ describe('validateMarkdownSource — attributes and structure', () => {
71
74
  });
72
75
  });
73
76
 
77
+ describe('validateMarkdownSource — conflicting annotation keys', () => {
78
+ it('flags a block-meta key set differently in both {[…]} and {…}', () => {
79
+ const result = validateMarkdownSource('## X {[sectionHeader duration=8]} {#x duration=10}\n');
80
+ const d = result.diagnostics.find((x) => x.code === 'conflicting-annotation-key');
81
+ expect(d).toBeDefined();
82
+ expect(d!.severity).toBe('info');
83
+ expect(d!.message).toBe(
84
+ '"duration" is set in both {[…]} (8) and {…} (10) — the {…} value wins.',
85
+ );
86
+ expect(d!.blockId).toBe('x');
87
+ expect(d!.line).toBe(1);
88
+ expect(result.infoCount).toBe(1);
89
+ expect(result.errorCount).toBe(0);
90
+ expect(result.warningCount).toBe(0);
91
+ // Precedence itself is unchanged: the Pandoc value is in effect.
92
+ expect(result.doc.blocks[0].duration).toBe(10);
93
+ });
94
+
95
+ it('stays silent when both forms agree', () => {
96
+ expect(codes('## X {[sectionHeader duration=8]} {#x duration=8}\n')).toEqual([]);
97
+ });
98
+
99
+ it('ignores non-block-meta keys (they land in different channels)', () => {
100
+ // `style` goes to templateOverrides ({[…]}) and metadata ({…}) — no conflict.
101
+ expect(codes('## X {[factCard style=minimal]} {#x style=bold}\n')).toEqual([]);
102
+ });
103
+
104
+ it('reports each conflicting key separately', () => {
105
+ const result = validateMarkdownSource(
106
+ '## X {[sectionHeader duration=8 startTime=1]} {#x duration=10 startTime=2}\n',
107
+ );
108
+ const found = result.diagnostics.filter((d) => d.code === 'conflicting-annotation-key');
109
+ expect(found).toHaveLength(2);
110
+ expect(result.infoCount).toBe(2);
111
+ });
112
+ });
113
+
74
114
  describe('validateMarkdownSource — assets', () => {
75
115
  it('flags relative image references missing from the asset set', () => {
76
116
  const result = validateMarkdownSource('## X\n\n![hero](images/hero.jpg)\n', {
@@ -98,3 +138,123 @@ describe('validateMarkdownSource — assets', () => {
98
138
  expect(codes('## X\n\n![hero](images/hero.jpg)\n')).toEqual([]);
99
139
  });
100
140
  });
141
+
142
+ describe('validateMarkdownSource — template inputs', () => {
143
+ it('flags an unknown input with a did-you-mean suggestion', () => {
144
+ const result = validateMarkdownSource('## Where {[map centre="47.6,-122.3"]}');
145
+ const d = result.diagnostics.find((x) => x.code === 'unknown-input');
146
+ expect(d).toBeDefined();
147
+ expect(d!.severity).toBe('warning');
148
+ expect(d!.message).toContain('centre');
149
+ expect(d!.message).toContain('Did you mean "center"?');
150
+ expect(d!.line).toBe(1);
151
+ });
152
+
153
+ it('flags a value outside a closed enum', () => {
154
+ const result = validateMarkdownSource('## Where {[map center="1,2" mapStyle="nope"]}');
155
+ const d = result.diagnostics.find((x) => x.code === 'invalid-input-value');
156
+ expect(d).toBeDefined();
157
+ expect(d!.message).toContain('mapStyle');
158
+ expect(d!.message).toContain('terrain');
159
+ });
160
+
161
+ it('flags a missing required input', () => {
162
+ const codesFor = codes('## Compare {[twoColumn]}');
163
+ expect(codesFor.filter((c) => c === 'missing-input')).toEqual([
164
+ 'missing-input',
165
+ 'missing-input',
166
+ ]);
167
+ });
168
+
169
+ it('does not fire missing-input when the value is derivable from the body', () => {
170
+ const md = '## Caption {[imageWithCaption]}\n\n![a beach](beach.jpg)\n';
171
+ // imageSrc is required, but derivable from the body image → no warning.
172
+ expect(codes(md)).not.toContain('missing-input');
173
+ });
174
+
175
+ it('does fire missing-input when the value is not derivable and absent', () => {
176
+ // No body image → imageSrc is neither present nor derivable.
177
+ expect(codes('## Caption {[imageWithCaption]}')).toContain('missing-input');
178
+ });
179
+
180
+ it('does not lint blocks that resolve to a doc-declared custom template', () => {
181
+ // A custom template that shadows the built-in "map" name carries its own
182
+ // arbitrary inputs — the built-in descriptors must not be applied to it.
183
+ const custom: CustomTemplateDefinition = {
184
+ name: 'map',
185
+ label: 'My Map',
186
+ description: 'custom',
187
+ viewport: { width: 1920, height: 1080 },
188
+ layers: [
189
+ {
190
+ id: 't',
191
+ type: 'text',
192
+ position: { x: '5%', y: '10%' },
193
+ content: { text: '{title}', style: { fontSize: 40, color: '#000' } },
194
+ },
195
+ ],
196
+ };
197
+ const encoded = writeCustomTemplatesToFrontmatter([custom])!;
198
+ const md = `---\nsquisq-custom-templates: ${encoded}\n---\n\n## Hi {[map foo="bar"]}\n`;
199
+ const codesFor = validateMarkdownSource(md).diagnostics.map((d) => d.code);
200
+ expect(codesFor).not.toContain('unknown-input');
201
+ expect(codesFor).not.toContain('missing-input');
202
+ });
203
+
204
+ it('does not lint templates without descriptors', () => {
205
+ // sectionHeader has no input descriptors → arbitrary keys are not flagged.
206
+ expect(codes('## Break {[sectionHeader whatever=5]}')).toEqual([]);
207
+ });
208
+
209
+ it('leaves a clean, fully-specified block with no findings', () => {
210
+ const md = '## Where {[map center="47.6,-122.3" zoom=9 mapStyle="terrain"]}';
211
+ expect(codes(md)).toEqual([]);
212
+ });
213
+ });
214
+
215
+ describe('validateMarkdownSource — standalone annotations (S2)', () => {
216
+ it('does not flag a valid standalone template annotation as unparsed', () => {
217
+ const result = validateMarkdownSource('# H\n\n{[quote]}\n\nquoted\n');
218
+ expect(result.diagnostics.find((d) => d.code === 'unparsed-annotation')).toBeUndefined();
219
+ expect(result.diagnostics.find((d) => d.code === 'unknown-template')).toBeUndefined();
220
+ });
221
+
222
+ it('reports unknown-template for a standalone annotation with a bad name', () => {
223
+ const result = validateMarkdownSource('# H\n\n{[quotte]}\n\nquoted\n');
224
+ const d = result.diagnostics.find((x) => x.code === 'unknown-template');
225
+ expect(d).toBeDefined();
226
+ expect(d!.message).toContain('quotte');
227
+ // Not double-reported as an unparsed annotation.
228
+ expect(result.diagnostics.find((x) => x.code === 'unparsed-annotation')).toBeUndefined();
229
+ });
230
+
231
+ it('still flags {[…]} nested inside a list (not a standalone block)', () => {
232
+ const result = validateMarkdownSource('## X\n\n- {[imageWithCaption src=x.jpg]}\n');
233
+ const d = result.diagnostics.find((x) => x.code === 'unparsed-annotation');
234
+ expect(d).toBeDefined();
235
+ expect(d!.message).toContain('standalone paragraphs');
236
+ });
237
+ });
238
+
239
+ describe('validateMarkdownSource — possible-data-fence (S3)', () => {
240
+ const info = (md: string) =>
241
+ validateMarkdownSource(md).diagnostics.filter((d) => d.code === 'possible-data-fence');
242
+
243
+ it('nudges an unmarked json fence inside a template-annotated block', () => {
244
+ const md = '## Numbers {[dataTable]}\n\n```json\n{ "headers": ["A"], "rows": [] }\n```\n';
245
+ const d = info(md);
246
+ expect(d).toHaveLength(1);
247
+ expect(d[0].severity).toBe('info');
248
+ expect(d[0].message).toContain('```json data');
249
+ });
250
+
251
+ it('does not fire for a code sample in a non-templated prose section', () => {
252
+ const md = '## Notes\n\n```json\n{ "a": 1 }\n```\n';
253
+ expect(info(md)).toHaveLength(0);
254
+ });
255
+
256
+ it('does not fire when the fence already has the data marker', () => {
257
+ const md = '## Numbers {[dataTable]}\n\n```json data\n{ "headers": ["A"], "rows": [] }\n```\n';
258
+ expect(info(md)).toHaveLength(0);
259
+ });
260
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Standalone template-annotation block extraction.
3
+ *
4
+ * A paragraph whose entire trimmed text is exactly `{[templateName key=value …]}`
5
+ * — where `templateName` is not a media name (media annotations are extracted
6
+ * first, into typed clips) — becomes a *heading-less template block*. The body
7
+ * nodes that follow it, up to the next heading or the next standalone
8
+ * annotation, become that block's `contents`.
9
+ *
10
+ * This is the block analog of {@link import('./mediaAnnotations.js').extractMediaFromContents}:
11
+ * both walk a block's body `contents` and lift standalone `{[…]}` paragraphs
12
+ * out, sharing the {@link parseStandaloneAnnotation} parser. Runs AFTER media
13
+ * extraction (so media names keep their meaning) and BEFORE structured-data
14
+ * parsing (so `json data` / `yaml data` fences attach to the new blocks).
15
+ */
16
+
17
+ import type { Block } from '../schemas/Doc.js';
18
+ import type { MarkdownBlockNode } from '../markdown/types.js';
19
+ import { coerceAnnotationValues, type CoercedBlockMeta } from '../markdown/annotationCoercion.js';
20
+ import { resolveTemplateName } from './templates/index.js';
21
+ import { isMediaAnnotationName } from './mediaAnnotations.js';
22
+ import { parseStandaloneAnnotation, type ParsedAnnotation } from './standaloneAnnotation.js';
23
+
24
+ /** Configuration for {@link extractTemplateBlocksFromContents}. */
25
+ export interface StandaloneBlockOptions {
26
+ /** Produce a unique block id for a parsed standalone annotation. */
27
+ makeId: (parsed: ParsedAnnotation) => string;
28
+ /** Initial `duration` for a produced block (the timing pass overrides it). */
29
+ defaultDuration: number;
30
+ }
31
+
32
+ /** The split produced by {@link extractTemplateBlocksFromContents}. */
33
+ export interface TemplateBlockExtraction {
34
+ /** Contents before the first standalone template annotation (stay on the
35
+ * containing block). */
36
+ leading: MarkdownBlockNode[];
37
+ /** Heading-less blocks, each carrying the body nodes that followed its
38
+ * annotation up to the next annotation / end of contents. */
39
+ blocks: Block[];
40
+ }
41
+
42
+ /**
43
+ * Apply coerced block-meta values onto a produced block. Mirrors
44
+ * `markdownToDoc`'s `applyBlockMeta` so a standalone `{[quote duration=8]}`
45
+ * honors the same block-meta keys a heading `{[…]}` annotation would.
46
+ */
47
+ function applyBlockMeta(block: Block, m: CoercedBlockMeta): void {
48
+ if (m.x != null) block.x = m.x;
49
+ if (m.y != null) block.y = m.y;
50
+ if (m.startTime != null) block.startTime = m.startTime;
51
+ if (m.duration != null) block.duration = m.duration;
52
+ if (m.connectsTo) block.connectsTo = m.connectsTo;
53
+ if (m.transition) block.transition = m.transition;
54
+ }
55
+
56
+ /** Build a heading-less block from a parsed standalone template annotation. */
57
+ function makeStandaloneBlock(parsed: ParsedAnnotation, options: StandaloneBlockOptions): Block {
58
+ // `parsed.template` is guaranteed defined by the caller's guard.
59
+ const rawTemplate = parsed.template as string;
60
+ const params = parsed.params;
61
+ const hasParams = Object.keys(params).length > 0;
62
+
63
+ const block: Block = {
64
+ id: options.makeId(parsed),
65
+ startTime: 0,
66
+ duration: options.defaultDuration,
67
+ audioSegment: 0,
68
+ template: resolveTemplateName(rawTemplate),
69
+ standaloneAnnotation: true,
70
+ // Keep the raw (un-resolved) name so validation can report the exact
71
+ // requested name — symmetric with `sourceHeading.templateAnnotation`.
72
+ sourceAnnotation: { template: rawTemplate, ...(hasParams ? { params } : {}) },
73
+ // Only carry a display title when the author supplied one.
74
+ ...(params.title ? { title: params.title } : {}),
75
+ };
76
+
77
+ if (hasParams) {
78
+ block.templateOverrides = params;
79
+ applyBlockMeta(block, coerceAnnotationValues(params).blockMeta);
80
+ }
81
+
82
+ return block;
83
+ }
84
+
85
+ /**
86
+ * Walk `contents`, lifting standalone template-annotation paragraphs into
87
+ * heading-less blocks. Nodes after an annotation (until the next annotation
88
+ * or the end of `contents`) attach to that block. Media annotations are left
89
+ * in place — they are extracted separately, before this runs. `contents` is
90
+ * not mutated; the split is returned.
91
+ */
92
+ export function extractTemplateBlocksFromContents(
93
+ contents: MarkdownBlockNode[] | undefined,
94
+ options: StandaloneBlockOptions,
95
+ ): TemplateBlockExtraction {
96
+ const leading: MarkdownBlockNode[] = [];
97
+ const blocks: Block[] = [];
98
+ let current: Block | null = null;
99
+
100
+ for (const node of contents ?? []) {
101
+ const parsed = parseStandaloneAnnotation(node);
102
+ const isTemplateAnnotation =
103
+ parsed != null && parsed.template != null && !isMediaAnnotationName(parsed.template);
104
+
105
+ if (isTemplateAnnotation) {
106
+ const block = makeStandaloneBlock(parsed as ParsedAnnotation, options);
107
+ blocks.push(block);
108
+ current = block;
109
+ } else if (current) {
110
+ (current.contents ??= []).push(node);
111
+ } else {
112
+ leading.push(node);
113
+ }
114
+ }
115
+
116
+ return { leading, blocks };
117
+ }
@@ -225,6 +225,7 @@ export function readCustomTemplatesFromFrontmatter(
225
225
  */
226
226
  export function writeCustomTemplatesToFrontmatter(
227
227
  templates: readonly CustomTemplateDefinition[] | undefined,
228
+ options?: { pretty?: boolean },
228
229
  ): string | undefined {
229
230
  if (!templates || templates.length === 0) return undefined;
230
231
  const map: Record<string, Record<string, unknown>> = {};
@@ -235,7 +236,12 @@ export function writeCustomTemplatesToFrontmatter(
235
236
  entry.ly = renameKeys(def.layers, LONG_TO_SHORT);
236
237
  map[def.name] = entry;
237
238
  }
238
- return JSON.stringify(map);
239
+ // Pretty output is multi-line, so it rides out as a YAML literal block
240
+ // scalar (see `formatBlockScalar` / stringify's frontmatter path) and
241
+ // parses back via `JSON.parse`. Default stays compact (single line) so
242
+ // existing docs and snapshots are byte-unchanged. The compact key codec
243
+ // (LONG_TO_SHORT) is applied in both modes.
244
+ return JSON.stringify(map, null, options?.pretty ? 2 : undefined);
239
245
  }
240
246
 
241
247
  function readViewport(raw: unknown): { width: number; height: number } {
@@ -82,9 +82,14 @@ export function readCustomThemesFromFrontmatter(
82
82
  */
83
83
  export function writeCustomThemesToFrontmatter(
84
84
  themes: readonly Theme[] | undefined,
85
+ options?: { pretty?: boolean },
85
86
  ): string | undefined {
86
87
  if (!themes || themes.length === 0) return undefined;
87
88
  const map: Record<string, Theme> = {};
88
89
  for (const theme of themes) map[theme.id] = theme;
89
- return JSON.stringify(map);
90
+ // Pretty output is multi-line, so it serializes as a YAML literal block
91
+ // scalar (see the markdown stringify frontmatter path) and reads back via
92
+ // `JSON.parse`. Default stays compact (single line) so existing docs and
93
+ // snapshots are byte-unchanged.
94
+ return JSON.stringify(map, null, options?.pretty ? 2 : undefined);
90
95
  }
@@ -23,7 +23,13 @@
23
23
  */
24
24
 
25
25
  import type { Doc, Block } from '../schemas/Doc.js';
26
- import type { MarkdownDocument, MarkdownBlockNode, MarkdownHeading } from '../markdown/types.js';
26
+ import type {
27
+ MarkdownDocument,
28
+ MarkdownBlockNode,
29
+ MarkdownHeading,
30
+ MarkdownParagraph,
31
+ } from '../markdown/types.js';
32
+ import { serializeAnnotation } from '../markdown/attrTokens.js';
27
33
  import {
28
34
  FRONTMATTER_CUSTOM_TEMPLATES_KEY,
29
35
  writeCustomTemplatesToFrontmatter,
@@ -55,6 +61,12 @@ export function docToMarkdown(doc: Doc): MarkdownDocument {
55
61
  if (block.sourceHeading) {
56
62
  const heading = ensureAnnotation(block, block.sourceHeading);
57
63
  children.push(heading);
64
+ } else if (block.standaloneAnnotation) {
65
+ // Heading-less standalone block: re-emit its `{[…]}` annotation as a
66
+ // paragraph before its contents. The serializer's quoting is paired
67
+ // with the tokenizer, and stringifyMarkdown un-escapes `{[…]}` spans,
68
+ // so the annotation round-trips.
69
+ children.push(synthesizeAnnotationParagraph(block));
58
70
  }
59
71
 
60
72
  // Emit body content
@@ -107,6 +119,16 @@ export function docToMarkdown(doc: Doc): MarkdownDocument {
107
119
  };
108
120
  }
109
121
 
122
+ /**
123
+ * Build the synthesized annotation paragraph for a heading-less standalone
124
+ * block: `{[templateName key=value …]}` from its `sourceAnnotation.template`
125
+ * and current `templateOverrides` (edits round-trip through the latter).
126
+ */
127
+ function synthesizeAnnotationParagraph(block: Block): MarkdownParagraph {
128
+ const text = serializeAnnotation(block.sourceAnnotation?.template, block.templateOverrides);
129
+ return { type: 'paragraph', children: [{ type: 'text', value: text }] };
130
+ }
131
+
110
132
  /**
111
133
  * Ensure the heading's `templateAnnotation` reflects the block's
112
134
  * template and templateOverrides. Returns a (possibly cloned) heading.
@@ -148,6 +170,14 @@ function ensureTransitionAttributes(
148
170
  const transition = block.transition;
149
171
  if (!transition) return heading.attributes;
150
172
 
173
+ // The transition is deliberately written to BOTH channels, which serve
174
+ // different consumers:
175
+ // - `params` is what `stringifyMarkdown` serializes into the Pandoc `{…}`
176
+ // attribute text (serializePandocAttributes reads only id/classes/params).
177
+ // - `blockMeta` is what `markdownToDoc` reads back directly (makeBlock,
178
+ // pinnedHeadingMeta) — an in-memory Doc → MarkdownDocument → Doc round
179
+ // trip never re-parses params, so a stale/missing `blockMeta.transition`
180
+ // would lose or resurrect the wrong transition.
151
181
  const attrs = heading.attributes ?? {};
152
182
  return {
153
183
  ...attrs,
@@ -35,6 +35,7 @@ import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
35
35
  import { createTemplateContext, isTemplateBlock } from '../schemas/BlockTemplates.js';
36
36
  import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
37
37
  import { templateRegistry, resolveTemplateName } from './templates/index.js';
38
+ import { coerceTemplateParams } from './templates/inputDescriptors.js';
38
39
  import { expandPersistentLayers, wrapWithPersistentLayers } from './templates/persistentLayers.js';
39
40
  import { applyRenderStyleToLayers } from './utils/applyRenderStyle.js';
40
41
  import { fallbackBlockLayers } from './templates/fallbackBlock.js';
@@ -121,11 +122,19 @@ export function getLayers(block: DocBlock, context: RenderContext = {}): Layer[]
121
122
  }
122
123
  // Effective template input: the block's own fields, then structured
123
124
  // body data (```json data fences, GFM tables), then `{[…]}` string
124
- // overrides — the same merge order buildPreviewDoc uses.
125
+ // overrides — the same merge order buildPreviewDoc uses. Inline overrides
126
+ // are coerced to their typed shape (`center="47.6,-122.3"` → `{lat,lng}`,
127
+ // `zoom=9` → `9`) so pure-inline annotations render without any change to
128
+ // the template functions. `block.templateOverrides` itself stays raw
129
+ // strings for lossless round-tripping — only this ephemeral input is coerced.
125
130
  const { templateData, templateOverrides } = block as Block;
126
131
  const input =
127
132
  templateData || templateOverrides
128
- ? ({ ...block, ...templateData, ...templateOverrides } as TemplateBlock)
133
+ ? ({
134
+ ...block,
135
+ ...templateData,
136
+ ...coerceTemplateParams(block.template, templateOverrides ?? {}).input,
137
+ } as TemplateBlock)
129
138
  : block;
130
139
  let layers: Layer[];
131
140
  try {
package/src/doc/index.ts CHANGED
@@ -33,6 +33,7 @@ export { resolveAudioMapping, scoreTextSimilarity } from './audioMapping.js';
33
33
  export {
34
34
  isDataFence,
35
35
  parseDataFence,
36
+ replaceDataFence,
36
37
  parseYamlSubset,
37
38
  findFirstTable,
38
39
  extractTableData,
@@ -41,3 +42,14 @@ export type { DataFenceParseResult, ExtractedTableData } from './structuredData.
41
42
  export { validateMarkdownSource, validateMarkdownDoc } from './validate.js';
42
43
  export type { ValidateOptions, MarkdownValidationResult } from './validate.js';
43
44
  export { fallbackBlockLayers } from './templates/fallbackBlock.js';
45
+ export {
46
+ BASE_INPUT_DESCRIPTORS,
47
+ TEMPLATE_INPUT_DESCRIPTORS,
48
+ coerceTemplateParams,
49
+ lintTemplateParams,
50
+ } from './templates/inputDescriptors.js';
51
+ export type {
52
+ InputCoercion,
53
+ TemplateInputDescriptor,
54
+ TemplateParamFinding,
55
+ } from './templates/inputDescriptors.js';