@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
@@ -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,
@@ -33,6 +39,8 @@ import {
33
39
  writeCustomThemesToFrontmatter,
34
40
  } from './customThemesFrontmatter.js';
35
41
 
42
+ const TRANSITION_PARAM_KEYS = ['transition', 'transitionDuration', 'transitionDirection'] as const;
43
+
36
44
  /**
37
45
  * Convert a Doc with heading-driven blocks back to a MarkdownDocument.
38
46
  *
@@ -55,6 +63,12 @@ export function docToMarkdown(doc: Doc): MarkdownDocument {
55
63
  if (block.sourceHeading) {
56
64
  const heading = ensureAnnotation(block, block.sourceHeading);
57
65
  children.push(heading);
66
+ } else if (block.standaloneAnnotation) {
67
+ // Heading-less standalone block: re-emit its `{[…]}` annotation as a
68
+ // paragraph before its contents. The serializer's quoting is paired
69
+ // with the tokenizer, and stringifyMarkdown un-escapes `{[…]}` spans,
70
+ // so the annotation round-trips.
71
+ children.push(synthesizeAnnotationParagraph(block));
58
72
  }
59
73
 
60
74
  // Emit body content
@@ -107,61 +121,96 @@ export function docToMarkdown(doc: Doc): MarkdownDocument {
107
121
  };
108
122
  }
109
123
 
124
+ /**
125
+ * Build the synthesized annotation paragraph for a heading-less standalone
126
+ * block: `{[templateName key=value …]}` from its `sourceAnnotation.template`
127
+ * and current `templateOverrides` (edits round-trip through the latter).
128
+ */
129
+ function synthesizeAnnotationParagraph(block: Block): MarkdownParagraph {
130
+ const text = serializeAnnotation(block.sourceAnnotation?.template, block.templateOverrides);
131
+ return { type: 'paragraph', children: [{ type: 'text', value: text }] };
132
+ }
133
+
110
134
  /**
111
135
  * Ensure the heading's `templateAnnotation` reflects the block's
112
136
  * template and templateOverrides. Returns a (possibly cloned) heading.
113
137
  */
114
138
  function ensureAnnotation(block: Block, heading: MarkdownHeading): MarkdownHeading {
115
- const attrs = ensureTransitionAttributes(block, heading);
139
+ const { attributes, transitionParams } = ensureTransitionMetadata(block, heading);
116
140
 
117
141
  // If the block has a non-default template or overrides, inject an annotation
118
142
  const hasExplicitTemplate =
119
143
  block.template && block.template !== 'sectionHeader' && block.autoTemplate !== true;
120
144
  const hasOverrides = block.templateOverrides && Object.keys(block.templateOverrides).length > 0;
121
145
 
122
- // If the heading already has an annotation, trust it (it came from parsing).
123
- // Transition attrs may still have been injected above if the block changed.
124
- if (heading.templateAnnotation) {
125
- return attrs === heading.attributes ? heading : { ...heading, attributes: attrs };
146
+ let templateAnnotation = heading.templateAnnotation;
147
+ if (!templateAnnotation && (hasExplicitTemplate || hasOverrides || transitionParams)) {
148
+ templateAnnotation = {
149
+ ...(hasExplicitTemplate ? { template: block.template ?? 'sectionHeader' } : {}),
150
+ ...(hasOverrides ? { params: block.templateOverrides } : {}),
151
+ };
126
152
  }
127
153
 
128
- if (!hasExplicitTemplate && !hasOverrides) {
129
- return attrs === heading.attributes ? heading : { ...heading, attributes: attrs };
154
+ if (transitionParams) {
155
+ templateAnnotation = {
156
+ ...(templateAnnotation ?? {}),
157
+ params: {
158
+ ...(templateAnnotation?.params ?? {}),
159
+ ...transitionParams,
160
+ },
161
+ };
130
162
  }
131
163
 
132
- // Clone to avoid mutating the original
133
- return {
134
- ...heading,
135
- children: [...heading.children],
136
- attributes: attrs,
137
- templateAnnotation: {
138
- template: block.template ?? 'sectionHeader',
139
- ...(hasOverrides ? { params: block.templateOverrides } : {}),
140
- },
141
- };
164
+ if (attributes === heading.attributes && templateAnnotation === heading.templateAnnotation) {
165
+ return heading;
166
+ }
167
+
168
+ return { ...heading, children: [...heading.children], attributes, templateAnnotation };
142
169
  }
143
170
 
144
- function ensureTransitionAttributes(
171
+ function ensureTransitionMetadata(
145
172
  block: Block,
146
173
  heading: MarkdownHeading,
147
- ): MarkdownHeading['attributes'] {
174
+ ): {
175
+ attributes: MarkdownHeading['attributes'];
176
+ transitionParams: Record<string, string> | undefined;
177
+ } {
148
178
  const transition = block.transition;
149
- if (!transition) return heading.attributes;
179
+ if (!transition) {
180
+ return { attributes: heading.attributes, transitionParams: undefined };
181
+ }
150
182
 
151
- const attrs = heading.attributes ?? {};
152
- return {
183
+ // The transition is written to the squisq-native `{[…]}` params for
184
+ // author-facing markdown, and to `attributes.blockMeta` for the in-memory
185
+ // Doc → MarkdownDocument → Doc path (which does not re-parse string params).
186
+ const attrs = removeTransitionParams(heading.attributes) ?? {};
187
+ const attributes = {
153
188
  ...attrs,
154
- params: {
155
- ...(attrs.params ?? {}),
189
+ blockMeta: {
190
+ ...(attrs.blockMeta ?? {}),
191
+ transition,
192
+ },
193
+ };
194
+ return {
195
+ attributes,
196
+ transitionParams: {
156
197
  transition: transition.type,
157
198
  ...(transition.duration !== undefined
158
199
  ? { transitionDuration: String(transition.duration) }
159
200
  : {}),
160
201
  ...(transition.direction ? { transitionDirection: transition.direction } : {}),
161
202
  },
162
- blockMeta: {
163
- ...(attrs.blockMeta ?? {}),
164
- transition,
165
- },
203
+ };
204
+ }
205
+
206
+ function removeTransitionParams(
207
+ attrs: MarkdownHeading['attributes'],
208
+ ): MarkdownHeading['attributes'] {
209
+ if (!attrs?.params) return attrs;
210
+ const params = { ...attrs.params };
211
+ for (const key of TRANSITION_PARAM_KEYS) delete params[key];
212
+ return {
213
+ ...attrs,
214
+ ...(Object.keys(params).length > 0 ? { params } : { params: undefined }),
166
215
  };
167
216
  }
@@ -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';