@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.
- 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-4ZFRZBU2.js → chunk-5TYS5V3G.js} +866 -192
- package/dist/chunk-5TYS5V3G.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-OH43RPTP.js → chunk-BEQODJWV.js} +20 -3
- package/dist/chunk-BEQODJWV.js.map +1 -0
- package/dist/chunk-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/{chunk-KZNXZ5SM.js → chunk-GPLTCSXO.js} +18 -7
- package/dist/chunk-GPLTCSXO.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-RUDYOTA6.js → chunk-LTN6I7QW.js} +56 -1
- package/dist/chunk-LTN6I7QW.js.map +1 -0
- package/dist/{chunk-2OIBZYKP.js → chunk-P3K7NLHF.js} +2 -2
- package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.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 +24 -9
- 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/imageEdit/index.js +2 -2
- package/dist/index.d.ts +8 -8
- package/dist/index.js +41 -26
- 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 +151 -13
- package/dist/story/index.js +24 -9
- 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 +79 -3
- 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 +173 -2
- package/src/__tests__/mediaAnnotations.test.ts +27 -0
- package/src/__tests__/shapeGeometry.test.ts +37 -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 +78 -29
- 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 +78 -15
- package/src/doc/templates/customTemplate.ts +10 -4
- package/src/doc/templates/diagramBlock.ts +2 -2
- package/src/doc/templates/drawingBlock.ts +2 -2
- 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/utils/shapeGeometry.ts +90 -2
- 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/convert.ts +11 -1
- 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/dist/chunk-RUDYOTA6.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-2OIBZYKP.js.map → chunk-P3K7NLHF.js.map} +0 -0
- /package/dist/{chunk-LRCV7N7F.js.map → chunk-UY7KGQ5R.js.map} +0 -0
|
@@ -250,6 +250,31 @@ describe('docToMarkdown (round-trip)', () => {
|
|
|
250
250
|
expect(stripPositions(md2)).toEqual(stripPositions(md));
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
it('round-trips inline coerced template params, keeping overrides raw', () => {
|
|
254
|
+
const input = '# Downtown {[map center="47.6,-122.3" zoom=9 mapStyle="road"]}\n\nSome text.\n';
|
|
255
|
+
const md = parseMarkdown(input);
|
|
256
|
+
const doc = markdownToDoc(md, { generateCoverBlock: false });
|
|
257
|
+
|
|
258
|
+
// The overrides must stay RAW strings — coercion only ever touches the
|
|
259
|
+
// ephemeral render input, never the stored block, so round-trips are lossless.
|
|
260
|
+
expect(doc.blocks[0].templateOverrides).toEqual({
|
|
261
|
+
center: '47.6,-122.3',
|
|
262
|
+
zoom: '9',
|
|
263
|
+
mapStyle: 'road',
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Byte-stable: one full cycle reaches a fixed point that a second cycle
|
|
267
|
+
// reproduces exactly.
|
|
268
|
+
const once = stringifyMarkdown(docToMarkdown(doc));
|
|
269
|
+
const twice = stringifyMarkdown(
|
|
270
|
+
docToMarkdown(markdownToDoc(parseMarkdown(once), { generateCoverBlock: false })),
|
|
271
|
+
);
|
|
272
|
+
expect(twice).toBe(once);
|
|
273
|
+
// And the annotation with its raw values survives serialization.
|
|
274
|
+
expect(once).toContain('center=47.6,-122.3');
|
|
275
|
+
expect(once).toContain('zoom=9');
|
|
276
|
+
});
|
|
277
|
+
|
|
253
278
|
it('round-trips preamble content', () => {
|
|
254
279
|
const input = 'Preamble text\n\n# Heading\n\nBody\n';
|
|
255
280
|
const md = parseMarkdown(input);
|
|
@@ -369,14 +394,62 @@ describe('template annotation in markdownToDoc', () => {
|
|
|
369
394
|
expect(output).toContain('{[factCard style=minimal]}');
|
|
370
395
|
});
|
|
371
396
|
|
|
372
|
-
it('docToMarkdown injects transition
|
|
397
|
+
it('docToMarkdown injects transition params into the squiggly annotation', () => {
|
|
373
398
|
const md = parseMarkdown('## Section\n\nBody');
|
|
374
399
|
const doc = markdownToDoc(md);
|
|
375
400
|
doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
|
|
376
401
|
|
|
377
402
|
const output = stringifyMarkdown(docToMarkdown(doc));
|
|
378
403
|
|
|
379
|
-
expect(output).toContain('{transition=wipe transitionDuration=0.8 transitionDirection=left}');
|
|
404
|
+
expect(output).toContain('{[transition=wipe transitionDuration=0.8 transitionDirection=left]}');
|
|
405
|
+
expect(output).not.toContain('{transition=wipe');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('a programmatic transition survives the string round trip', () => {
|
|
409
|
+
const doc = markdownToDoc(parseMarkdown('## Section\n\nBody'));
|
|
410
|
+
doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
|
|
411
|
+
|
|
412
|
+
const reparsed = markdownToDoc(parseMarkdown(stringifyMarkdown(docToMarkdown(doc))));
|
|
413
|
+
|
|
414
|
+
expect(reparsed.blocks[0].transition).toEqual({
|
|
415
|
+
type: 'wipe',
|
|
416
|
+
duration: 0.8,
|
|
417
|
+
direction: 'left',
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('a programmatic transition survives the in-memory round trip (no stringify)', () => {
|
|
422
|
+
// markdownToDoc reads heading.attributes.blockMeta directly — it never
|
|
423
|
+
// re-coerces params — so docToMarkdown must keep blockMeta in sync with
|
|
424
|
+
// the params it writes for stringifyMarkdown.
|
|
425
|
+
const doc = markdownToDoc(parseMarkdown('## Section\n\nBody'));
|
|
426
|
+
doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
|
|
427
|
+
|
|
428
|
+
const reconverted = markdownToDoc(docToMarkdown(doc));
|
|
429
|
+
|
|
430
|
+
expect(reconverted.blocks[0].transition).toEqual({
|
|
431
|
+
type: 'wipe',
|
|
432
|
+
duration: 0.8,
|
|
433
|
+
direction: 'left',
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('an updated transition replaces a previously authored one in both round trips', () => {
|
|
438
|
+
// The heading already carries a parsed transition (params + blockMeta);
|
|
439
|
+
// a programmatic change must win over the stale blockMeta.
|
|
440
|
+
const doc = markdownToDoc(parseMarkdown('## Section {transition=fade}\n\nBody'));
|
|
441
|
+
expect(doc.blocks[0].transition).toEqual({ type: 'fade' });
|
|
442
|
+
doc.blocks[0].transition = { type: 'wipe', direction: 'left' };
|
|
443
|
+
|
|
444
|
+
const inMemory = markdownToDoc(docToMarkdown(doc));
|
|
445
|
+
expect(inMemory.blocks[0].transition).toEqual({ type: 'wipe', direction: 'left' });
|
|
446
|
+
|
|
447
|
+
const output = stringifyMarkdown(docToMarkdown(doc));
|
|
448
|
+
expect(output).toContain('transition=wipe');
|
|
449
|
+
expect(output).toContain('{[transition=wipe transitionDirection=left]}');
|
|
450
|
+
expect(output).not.toContain('{transition=wipe');
|
|
451
|
+
const reparsed = markdownToDoc(parseMarkdown(output));
|
|
452
|
+
expect(reparsed.blocks[0].transition).toEqual({ type: 'wipe', direction: 'left' });
|
|
380
453
|
});
|
|
381
454
|
|
|
382
455
|
it('nested headings preserve their own annotations', () => {
|
|
@@ -784,3 +857,101 @@ describe('auto template picking (autoTemplates, default on)', () => {
|
|
|
784
857
|
expect(roundTripped).not.toContain('{[quote');
|
|
785
858
|
});
|
|
786
859
|
});
|
|
860
|
+
|
|
861
|
+
describe('standalone annotation blocks (S2)', () => {
|
|
862
|
+
const toDoc = (md: string, options?: Parameters<typeof markdownToDoc>[1]) =>
|
|
863
|
+
markdownToDoc(parseMarkdown(md), { articleId: 't', generateCoverBlock: false, ...options });
|
|
864
|
+
|
|
865
|
+
it('turns a standalone {[template]} paragraph into a heading-less block', () => {
|
|
866
|
+
const doc = toDoc('# Intro\n\nlead para\n\n{[quote]}\n\nquoted body\n');
|
|
867
|
+
// Containing block keeps only the leading content.
|
|
868
|
+
const intro = doc.blocks[0];
|
|
869
|
+
expect(intro.title).toBe('Intro');
|
|
870
|
+
expect(intro.contents?.map((n) => n.type)).toEqual(['paragraph']);
|
|
871
|
+
// The produced block is a sibling right after the containing block.
|
|
872
|
+
const q = doc.blocks[1];
|
|
873
|
+
expect(q.template).toBe('quote');
|
|
874
|
+
expect(q.standaloneAnnotation).toBe(true);
|
|
875
|
+
expect(q.sourceHeading).toBeUndefined();
|
|
876
|
+
expect(q.sourceAnnotation).toEqual({ template: 'quote' });
|
|
877
|
+
expect(q.contents?.map((n) => n.type)).toEqual(['paragraph']);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('attaches trailing contents up to the next annotation; params → overrides + title', () => {
|
|
881
|
+
const doc = toDoc(
|
|
882
|
+
'# H\n\n{[statHighlight title="Q1 Revenue" colorScheme=blue]}\n\nbig number\n\n{[quote]}\n\nq\n',
|
|
883
|
+
);
|
|
884
|
+
const stat = doc.blocks[1];
|
|
885
|
+
expect(stat.template).toBe('statHighlight');
|
|
886
|
+
expect(stat.title).toBe('Q1 Revenue');
|
|
887
|
+
expect(stat.templateOverrides).toEqual({ title: 'Q1 Revenue', colorScheme: 'blue' });
|
|
888
|
+
expect(stat.contents?.length).toBe(1); // "big number", ended by the next annotation
|
|
889
|
+
const quote = doc.blocks[2];
|
|
890
|
+
expect(quote.template).toBe('quote');
|
|
891
|
+
expect(quote.contents?.length).toBe(1); // "q"
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it('produces adjacent blocks for consecutive annotations (no content between)', () => {
|
|
895
|
+
const doc = toDoc('# H\n\n{[quote]}\n\n{[factCard]}\n\ntail\n');
|
|
896
|
+
expect(doc.blocks[1].template).toBe('quote');
|
|
897
|
+
expect(doc.blocks[1].contents ?? []).toHaveLength(0);
|
|
898
|
+
expect(doc.blocks[2].template).toBe('factCard');
|
|
899
|
+
expect(doc.blocks[2].contents?.length).toBe(1); // "tail"
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('handles an annotation as the last node (no trailing contents)', () => {
|
|
903
|
+
const doc = toDoc('# H\n\nbody\n\n{[quote]}\n');
|
|
904
|
+
expect(doc.blocks[1].template).toBe('quote');
|
|
905
|
+
expect(doc.blocks[1].contents ?? []).toHaveLength(0);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('generates unique slug ids that dedupe against headings', () => {
|
|
909
|
+
const doc = toDoc('# Quote\n\n{[quote title=Quote]}\n\nbody\n');
|
|
910
|
+
expect(doc.blocks[0].id).toBe('quote');
|
|
911
|
+
expect(doc.blocks[1].id).toBe('quote-2');
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('lifts an annotation out of the preamble (before the first heading)', () => {
|
|
915
|
+
const doc = toDoc('intro\n\n{[quote]}\n\nquoted\n\n# Heading\n\nrest\n');
|
|
916
|
+
// preamble keeps "intro", quote block next, then the heading block.
|
|
917
|
+
expect(doc.blocks[0].sourceHeading).toBeUndefined();
|
|
918
|
+
expect(doc.blocks[0].contents?.length).toBe(1);
|
|
919
|
+
expect(doc.blocks[1].template).toBe('quote');
|
|
920
|
+
expect(doc.blocks[2].title).toBe('Heading');
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it('drops a preamble that was entirely a standalone annotation', () => {
|
|
924
|
+
const doc = toDoc('{[quote]}\n\nquoted\n\n# Heading\n');
|
|
925
|
+
expect(doc.blocks[0].template).toBe('quote');
|
|
926
|
+
expect(doc.blocks[0].standaloneAnnotation).toBe(true);
|
|
927
|
+
expect(doc.blocks[1].title).toBe('Heading');
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('honors a pinned duration on a standalone annotation', () => {
|
|
931
|
+
const doc = toDoc('# H\n\n{[quote duration=9]}\n\nbody\n');
|
|
932
|
+
expect(doc.blocks[1].duration).toBe(9);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('does not treat annotations nested inside a list as blocks', () => {
|
|
936
|
+
const doc = toDoc('# H\n\n- {[quote]}\n');
|
|
937
|
+
// Only the heading block — the list annotation stays in contents.
|
|
938
|
+
expect(doc.blocks).toHaveLength(1);
|
|
939
|
+
expect(doc.blocks[0].contents?.[0].type).toBe('list');
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('round-trips a standalone annotation block byte-for-byte', () => {
|
|
943
|
+
const md = '# Intro\n\nlead\n\n{[quote]}\n\nquoted body\n';
|
|
944
|
+
const doc = toDoc(md);
|
|
945
|
+
const out = stringifyMarkdown(docToMarkdown(doc));
|
|
946
|
+
expect(out).toBe(md);
|
|
947
|
+
// And re-parsing is stable.
|
|
948
|
+
const doc2 = toDoc(out);
|
|
949
|
+
expect(stringifyMarkdown(docToMarkdown(doc2))).toBe(md);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it('round-trips params (quoting) byte-for-byte', () => {
|
|
953
|
+
const md = '# H\n\n{[statHighlight title="Q1 Revenue" colorScheme=blue]}\n\nbody\n';
|
|
954
|
+
const out = stringifyMarkdown(docToMarkdown(toDoc(md)));
|
|
955
|
+
expect(out).toBe(md);
|
|
956
|
+
});
|
|
957
|
+
});
|
|
@@ -44,6 +44,14 @@ describe('markdownToDoc media annotations', () => {
|
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
it('accepts startTime as an alias of startAt (startAt wins when both given)', () => {
|
|
48
|
+
const alias = toDoc('# B {duration=20}\n\n{[audio src=a.mp3 startTime=5]}\n');
|
|
49
|
+
expect(alias.blocks[0].media![0].startAt).toBe(5);
|
|
50
|
+
|
|
51
|
+
const both = toDoc('# B {duration=20}\n\n{[audio src=a.mp3 startAt=3 startTime=5]}\n');
|
|
52
|
+
expect(both.blocks[0].media![0].startAt).toBe(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
47
55
|
it('media annotation text does not inflate block duration', () => {
|
|
48
56
|
// No explicit duration; body is only the annotation → default (5s), not reading-time of the annotation text.
|
|
49
57
|
const doc = toDoc('# B\n\n{[audio src=a.mp3]}\n');
|
|
@@ -62,3 +70,22 @@ describe('markdownToDoc media annotations', () => {
|
|
|
62
70
|
expect(block.absoluteStart).toBe(35); // block Two at 30 + startAt 5
|
|
63
71
|
});
|
|
64
72
|
});
|
|
73
|
+
|
|
74
|
+
describe('media annotations vs standalone template blocks', () => {
|
|
75
|
+
it('extracts media annotations as clips, not standalone blocks (media names win)', () => {
|
|
76
|
+
const doc = toDoc('# One\n\nIntro.\n\n{[audio src=narration.mp3]}\n\n{[video src=clip.mp4]}\n');
|
|
77
|
+
const block = doc.blocks[0];
|
|
78
|
+
expect(block.media?.map((m) => m.kind)).toEqual(['audio', 'video']);
|
|
79
|
+
// No sibling standalone blocks were produced for the media names.
|
|
80
|
+
expect(doc.blocks).toHaveLength(1);
|
|
81
|
+
expect(doc.blocks.some((b) => b.standaloneAnnotation)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('mixes a media clip and a standalone template block in one section', () => {
|
|
85
|
+
const doc = toDoc('# One\n\n{[audio src=n.mp3]}\n\n{[quote]}\n\nquoted\n');
|
|
86
|
+
expect(doc.blocks[0].media?.map((m) => m.kind)).toEqual(['audio']);
|
|
87
|
+
const q = doc.blocks[1];
|
|
88
|
+
expect(q.template).toBe('quote');
|
|
89
|
+
expect(q.standaloneAnnotation).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -4,8 +4,11 @@ import {
|
|
|
4
4
|
markerPath,
|
|
5
5
|
connectorPath,
|
|
6
6
|
clipEndpoints,
|
|
7
|
+
nearestSnapPoint,
|
|
7
8
|
lineStyleDasharray,
|
|
8
9
|
PATH_SHAPE_KINDS,
|
|
10
|
+
snapEndpoints,
|
|
11
|
+
snapPoints,
|
|
9
12
|
} from '../doc/utils/shapeGeometry.js';
|
|
10
13
|
|
|
11
14
|
describe('shapePath', () => {
|
|
@@ -79,6 +82,40 @@ describe('clipEndpoints', () => {
|
|
|
79
82
|
});
|
|
80
83
|
});
|
|
81
84
|
|
|
85
|
+
describe('snapEndpoints', () => {
|
|
86
|
+
const a = { cx: 50, cy: 50, rx: 30, ry: 20 };
|
|
87
|
+
const b = { cx: 180, cy: 50, rx: 30, ry: 20 };
|
|
88
|
+
|
|
89
|
+
it('exposes side and corner ports around a box', () => {
|
|
90
|
+
expect(snapPoints(a).map((p) => p.port)).toEqual([
|
|
91
|
+
'top',
|
|
92
|
+
'right',
|
|
93
|
+
'bottom',
|
|
94
|
+
'left',
|
|
95
|
+
'top-left',
|
|
96
|
+
'top-right',
|
|
97
|
+
'bottom-right',
|
|
98
|
+
'bottom-left',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('snaps horizontal connections to side ports', () => {
|
|
103
|
+
const r = snapEndpoints(a, b);
|
|
104
|
+
expect(r.start).toMatchObject({ port: 'right', x: 80, y: 50 });
|
|
105
|
+
expect(r.end).toMatchObject({ port: 'left', x: 150, y: 50 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('snaps diagonal connections to nearest corner ports', () => {
|
|
109
|
+
const r = snapEndpoints(a, { cx: 180, cy: 140, rx: 30, ry: 20 });
|
|
110
|
+
expect(r.start).toMatchObject({ port: 'bottom-right', x: 80, y: 70 });
|
|
111
|
+
expect(r.end).toMatchObject({ port: 'top-left', x: 150, y: 120 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('finds the nearest port toward a free pointer', () => {
|
|
115
|
+
expect(nearestSnapPoint(a, { x: 100, y: 10 }).port).toBe('top-right');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
82
119
|
describe('lineStyleDasharray', () => {
|
|
83
120
|
it('maps line styles to dash patterns', () => {
|
|
84
121
|
expect(lineStyleDasharray('dashed')).toBe('8 6');
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { parseMarkdown } from '../markdown/parse.js';
|
|
3
3
|
import { markdownToDoc } from '../doc/markdownToDoc.js';
|
|
4
|
-
import { parseYamlSubset } from '../doc/structuredData.js';
|
|
4
|
+
import { parseYamlSubset, replaceDataFence } from '../doc/structuredData.js';
|
|
5
|
+
import type { MarkdownCodeBlock } from '../markdown/types.js';
|
|
5
6
|
|
|
6
7
|
function toDoc(md: string) {
|
|
7
8
|
return markdownToDoc(parseMarkdown(md));
|
|
@@ -41,8 +42,21 @@ describe('parseYamlSubset', () => {
|
|
|
41
42
|
expect(parseYamlSubset('# comment\n\nkey: value')).toEqual({ key: 'value' });
|
|
42
43
|
});
|
|
43
44
|
|
|
44
|
-
it('
|
|
45
|
-
expect(
|
|
45
|
+
it('parses one level of nested mapping', () => {
|
|
46
|
+
expect(parseYamlSubset('center:\n lat: 47.6\n lng: -122.3\nzoom: 12')).toEqual({
|
|
47
|
+
center: { lat: 47.6, lng: -122.3 },
|
|
48
|
+
zoom: 12,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects nesting deeper than one level with a line-anchored error', () => {
|
|
53
|
+
expect(() => parseYamlSubset('a:\n b:\n c: 1')).toThrow(/line 2/);
|
|
54
|
+
expect(() => parseYamlSubset('a:\n b: 1\n c: 2')).toThrow(/line 3/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects mixing list items and mapping keys under one key', () => {
|
|
58
|
+
expect(() => parseYamlSubset('a:\n - x\n b: 1')).toThrow(/mix/);
|
|
59
|
+
expect(() => parseYamlSubset('a:\n b: 1\n - x')).toThrow(/mix/);
|
|
46
60
|
});
|
|
47
61
|
});
|
|
48
62
|
|
|
@@ -93,6 +107,62 @@ describe('data fences → block.templateData', () => {
|
|
|
93
107
|
});
|
|
94
108
|
});
|
|
95
109
|
|
|
110
|
+
describe('replaceDataFence', () => {
|
|
111
|
+
it('replaces the first data fence content, keeping lang and meta', () => {
|
|
112
|
+
const doc = toDoc(
|
|
113
|
+
'## Numbers {[dataTable]}\n\nIntro.\n\n```json data\n{ "headers": ["Q"] }\n```',
|
|
114
|
+
);
|
|
115
|
+
const contents = doc.blocks[0].contents!;
|
|
116
|
+
|
|
117
|
+
const next = replaceDataFence(contents, { headers: ['Q', 'Revenue'] });
|
|
118
|
+
|
|
119
|
+
const fence = next.find((n): n is MarkdownCodeBlock => n.type === 'code')!;
|
|
120
|
+
expect(fence.lang).toBe('json');
|
|
121
|
+
expect(fence.meta).toBe('data');
|
|
122
|
+
expect(JSON.parse(fence.value)).toEqual({ headers: ['Q', 'Revenue'] });
|
|
123
|
+
// Pretty-printed, not single-line.
|
|
124
|
+
expect(fence.value).toContain('\n');
|
|
125
|
+
// Same length, other nodes untouched.
|
|
126
|
+
expect(next).toHaveLength(contents.length);
|
|
127
|
+
expect(next[0]).toBe(contents[0]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not mutate the input array or the original fence node', () => {
|
|
131
|
+
const doc = toDoc('## Numbers {[dataTable]}\n\n```json data\n{ "a": 1 }\n```');
|
|
132
|
+
const contents = doc.blocks[0].contents!;
|
|
133
|
+
const originalFence = contents.find((n): n is MarkdownCodeBlock => n.type === 'code')!;
|
|
134
|
+
|
|
135
|
+
replaceDataFence(contents, { a: 2 });
|
|
136
|
+
|
|
137
|
+
expect(originalFence.value).toBe('{ "a": 1 }');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('appends a new ```json data fence when none exists', () => {
|
|
141
|
+
const doc = toDoc('## Numbers {[dataTable]}\n\nJust text.');
|
|
142
|
+
const contents = doc.blocks[0].contents!;
|
|
143
|
+
|
|
144
|
+
const next = replaceDataFence(contents, { rows: [['Q1', '1.2M']] });
|
|
145
|
+
|
|
146
|
+
expect(next).toHaveLength(contents.length + 1);
|
|
147
|
+
const fence = next[next.length - 1] as MarkdownCodeBlock;
|
|
148
|
+
expect(fence).toMatchObject({ type: 'code', lang: 'json', meta: 'data' });
|
|
149
|
+
expect(JSON.parse(fence.value)).toEqual({ rows: [['Q1', '1.2M']] });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('treats plain code fences (no data marker) as non-fences and appends', () => {
|
|
153
|
+
const doc = toDoc('## Code {[sectionHeader]}\n\n```json\n{ "just": "a sample" }\n```');
|
|
154
|
+
const contents = doc.blocks[0].contents!;
|
|
155
|
+
|
|
156
|
+
const next = replaceDataFence(contents, { a: 1 });
|
|
157
|
+
|
|
158
|
+
// The sample fence is untouched; a data fence is appended after it.
|
|
159
|
+
const sample = next.find((n): n is MarkdownCodeBlock => n.type === 'code' && !n.meta)!;
|
|
160
|
+
expect(sample.value).toBe('{ "just": "a sample" }');
|
|
161
|
+
expect(next).toHaveLength(contents.length + 1);
|
|
162
|
+
expect((next[next.length - 1] as MarkdownCodeBlock).meta).toBe('data');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
96
166
|
describe('dataTable eats GFM tables', () => {
|
|
97
167
|
const table = ['| Name | Age |', '| --- | ---: |', '| Alice | 30 |', '| Bob | 25 |'].join('\n');
|
|
98
168
|
|
|
@@ -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');
|