@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.
- package/README.md +14 -1
- package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
- package/dist/{Doc-BsMkmzH8.d.ts → Doc-BBVGq1_9.d.ts} +94 -15
- package/dist/{ImageEditDoc-DvoeHMP9.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +1 -1
- package/dist/{chunk-KZNXZ5SM.js → chunk-6ATE2PSM.js} +18 -7
- package/dist/chunk-6ATE2PSM.js.map +1 -0
- package/dist/{chunk-7MQGNR76.js → chunk-A4O7GIWE.js} +2 -27
- package/dist/chunk-A4O7GIWE.js.map +1 -0
- package/dist/chunk-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
- package/dist/chunk-HWVFJAAH.js.map +1 -0
- package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
- package/dist/chunk-IMSCRLLV.js.map +1 -0
- package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
- package/dist/chunk-KKNUBQ6Y.js.map +1 -0
- package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
- package/dist/{chunk-4ZFRZBU2.js → chunk-LH7I6SH7.js} +777 -151
- package/dist/chunk-LH7I6SH7.js.map +1 -0
- package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.js.map +1 -0
- package/dist/{chunk-OH43RPTP.js → chunk-TEEEILMP.js} +12 -2
- package/dist/chunk-TEEEILMP.js.map +1 -0
- package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
- package/dist/chunk-TQWLI6S2.js.map +1 -0
- package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
- package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
- package/dist/chunk-VWUFZ6ZG.js.map +1 -0
- package/dist/doc/index.d.ts +4 -4
- package/dist/doc/index.js +16 -7
- package/dist/generate/index.d.ts +2 -80
- package/dist/generate/index.js +2 -7
- package/dist/imageEdit/index.d.ts +4 -4
- package/dist/index.d.ts +8 -8
- package/dist/index.js +32 -23
- package/dist/jsonForm/index.d.ts +42 -1
- package/dist/jsonForm/index.js +9 -1
- package/dist/markdown/index.d.ts +18 -2
- package/dist/markdown/index.js +5 -3
- package/dist/recommend/index.js +2 -2
- package/dist/schemas/index.d.ts +4 -4
- package/dist/schemas/index.js +3 -5
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +1 -1
- package/dist/story/index.d.ts +125 -11
- package/dist/story/index.js +16 -7
- package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
- package/dist/timing/index.js +3 -5
- package/dist/transform/index.d.ts +2 -2
- package/dist/transform/index.js +3 -3
- package/dist/versions/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/contentExtractor.test.ts +60 -0
- package/src/__tests__/coverBlock.test.ts +46 -1
- package/src/__tests__/customTemplateSample.test.ts +63 -2
- package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
- package/src/__tests__/inputDescriptors.test.ts +162 -0
- package/src/__tests__/jsonForm.tokens.test.ts +84 -0
- package/src/__tests__/markdown.test.ts +64 -0
- package/src/__tests__/markdownSanitize.test.ts +15 -0
- package/src/__tests__/markdownToDoc.test.ts +168 -0
- package/src/__tests__/mediaAnnotations.test.ts +27 -0
- package/src/__tests__/structuredData.test.ts +73 -3
- package/src/__tests__/templateMetadata.test.ts +11 -1
- package/src/__tests__/templates.test.ts +112 -0
- package/src/__tests__/validateDoc.test.ts +160 -0
- package/src/doc/annotationBlocks.ts +117 -0
- package/src/doc/customTemplatesFrontmatter.ts +7 -1
- package/src/doc/customThemesFrontmatter.ts +6 -1
- package/src/doc/docToMarkdown.ts +31 -1
- package/src/doc/getLayers.ts +11 -2
- package/src/doc/index.ts +12 -0
- package/src/doc/markdownToDoc.ts +80 -3
- package/src/doc/mediaAnnotations.ts +15 -33
- package/src/doc/standaloneAnnotation.ts +58 -0
- package/src/doc/structuredData.ts +92 -21
- package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
- package/src/doc/templates/comparisonBar.ts +8 -1
- package/src/doc/templates/coverBlock.ts +29 -2
- package/src/doc/templates/customTemplate.ts +10 -4
- package/src/doc/templates/factCard.ts +4 -2
- package/src/doc/templates/imageWithCaption.ts +10 -8
- package/src/doc/templates/index.ts +12 -2
- package/src/doc/templates/inputDescriptors.ts +432 -0
- package/src/doc/templates/photoGrid.ts +16 -39
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
- package/src/doc/templates/tokens/resolveTokens.ts +0 -0
- package/src/doc/templates/videoWithCaption.ts +15 -14
- package/src/doc/utils/nearest.ts +59 -0
- package/src/doc/validate.ts +153 -54
- package/src/generate/contentExtractor.ts +148 -35
- package/src/generate/index.ts +0 -3
- package/src/jsonForm/index.ts +2 -0
- package/src/jsonForm/tokens.ts +78 -0
- package/src/markdown/annotationCoercion.ts +1 -1
- package/src/markdown/attrTokens.ts +19 -0
- package/src/markdown/index.ts +2 -1
- package/src/markdown/sanitize.ts +22 -2
- package/src/markdown/stringify.ts +12 -3
- package/src/markdown/utils.ts +62 -1
- package/src/schemas/CustomTemplates.ts +56 -4
- package/src/schemas/Doc.ts +39 -41
- package/src/schemas/themeCompile.ts +1 -1
- package/src/storage/ContentContainer.ts +6 -2
- package/src/transform/types.ts +1 -1
- package/dist/chunk-2TMAWB4Y.js.map +0 -1
- package/dist/chunk-3E5F2XMR.js.map +0 -1
- package/dist/chunk-4AKUQPEE.js.map +0 -1
- package/dist/chunk-4X3JQXNM.js +0 -35
- package/dist/chunk-4X3JQXNM.js.map +0 -1
- package/dist/chunk-4ZFRZBU2.js.map +0 -1
- package/dist/chunk-7MQGNR76.js.map +0 -1
- package/dist/chunk-FBKP5CAO.js +0 -145
- package/dist/chunk-FBKP5CAO.js.map +0 -1
- package/dist/chunk-FR2RBTKO.js.map +0 -1
- package/dist/chunk-K32VJONL.js.map +0 -1
- package/dist/chunk-KZNXZ5SM.js.map +0 -1
- package/dist/chunk-OH43RPTP.js.map +0 -1
- package/dist/chunk-OKJOQXK4.js.map +0 -1
- package/src/__tests__/slideshowGenerator.test.ts +0 -128
- package/src/generate/slideshowGenerator.ts +0 -280
- /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
- /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\n', {
|
|
@@ -98,3 +138,123 @@ describe('validateMarkdownSource — assets', () => {
|
|
|
98
138
|
expect(codes('## X\n\n\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\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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/doc/docToMarkdown.ts
CHANGED
|
@@ -23,7 +23,13 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import type { Doc, Block } from '../schemas/Doc.js';
|
|
26
|
-
import type {
|
|
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,
|
package/src/doc/getLayers.ts
CHANGED
|
@@ -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
|
-
? ({
|
|
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';
|