@bendyline/squisq 1.4.1 → 1.5.0
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/dist/{Doc-BeSeH2iX.d.ts → Doc-BsMkmzH8.d.ts} +838 -268
- package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-DvoeHMP9.d.ts} +11 -4
- package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
- package/dist/chunk-2OIBZYKP.js.map +1 -0
- package/dist/chunk-2UQOHYE2.js +45 -0
- package/dist/chunk-2UQOHYE2.js.map +1 -0
- package/dist/{chunk-B4EEON3N.js → chunk-4AKUQPEE.js} +536 -103
- package/dist/chunk-4AKUQPEE.js.map +1 -0
- package/dist/chunk-4ZFRZBU2.js +6582 -0
- package/dist/chunk-4ZFRZBU2.js.map +1 -0
- package/dist/{chunk-57CVQCFX.js → chunk-7MQGNR76.js} +79 -3
- package/dist/chunk-7MQGNR76.js.map +1 -0
- package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
- package/dist/chunk-DLXZMS5K.js.map +1 -0
- package/dist/{chunk-MYIH7FWD.js → chunk-FBKP5CAO.js} +1 -1
- package/dist/chunk-FBKP5CAO.js.map +1 -0
- package/dist/chunk-KZNXZ5SM.js +333 -0
- package/dist/chunk-KZNXZ5SM.js.map +1 -0
- package/dist/{chunk-WMOI7FN3.js → chunk-LRCV7N7F.js} +12 -2
- package/dist/{chunk-WMOI7FN3.js.map → chunk-LRCV7N7F.js.map} +1 -1
- package/dist/{chunk-3K5OG6KI.js → chunk-OH43RPTP.js} +403 -187
- package/dist/chunk-OH43RPTP.js.map +1 -0
- package/dist/{chunk-H3AXU4MJ.js → chunk-OKJOQXK4.js} +149 -2
- package/dist/chunk-OKJOQXK4.js.map +1 -0
- package/dist/chunk-PGGGKYWG.js +1 -0
- package/dist/chunk-PGGGKYWG.js.map +1 -0
- package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
- package/dist/chunk-Q3ROPT5H.js.map +1 -0
- package/dist/chunk-QE7TWGVT.js +178 -0
- package/dist/chunk-QE7TWGVT.js.map +1 -0
- package/dist/chunk-RUDYOTA6.js +345 -0
- package/dist/chunk-RUDYOTA6.js.map +1 -0
- package/dist/chunk-TVSGLUID.js +596 -0
- package/dist/chunk-TVSGLUID.js.map +1 -0
- package/dist/chunk-ZLQKNOZQ.js +1 -0
- package/dist/chunk-ZLQKNOZQ.js.map +1 -0
- package/dist/doc/index.d.ts +4 -4
- package/dist/doc/index.js +124 -6
- package/dist/generate/index.d.ts +9 -2
- package/dist/generate/index.js +1 -1
- package/dist/icons/index.d.ts +3 -26
- package/dist/icons/index.js +13 -0
- package/dist/icons/inlineIconMarker.d.ts +1 -0
- package/dist/icons/inlineIconMarker.js +15 -0
- package/dist/icons/inlineIconMarker.js.map +1 -0
- package/dist/imageEdit/index.d.ts +11 -5
- package/dist/imageEdit/index.js +4 -1
- package/dist/index.d.ts +11 -8
- package/dist/index.js +243 -24
- package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
- package/dist/markdown/index.d.ts +212 -3
- package/dist/markdown/index.js +40 -7
- package/dist/recommend/index.d.ts +1 -1
- package/dist/recommend/index.js +3 -2
- package/dist/schemas/index.d.ts +28 -8
- package/dist/schemas/index.js +35 -5
- package/dist/spatial/index.d.ts +9 -4
- package/dist/spatial/index.js +1 -1
- package/dist/story/index.d.ts +906 -40
- package/dist/story/index.js +124 -6
- package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-DQQKuRMx.d.ts} +1 -1
- package/dist/transform/index.d.ts +48 -10
- package/dist/transform/index.js +17 -481
- package/dist/transform/index.js.map +1 -1
- package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
- package/package.json +6 -1
- package/src/__tests__/animationUtils.test.ts +40 -0
- package/src/__tests__/annotationCoercion.test.ts +243 -0
- package/src/__tests__/applyRenderStyle.test.ts +146 -0
- package/src/__tests__/atmosphereLayers.test.ts +108 -0
- package/src/__tests__/attrTokens.test.ts +162 -0
- package/src/__tests__/customTemplateSample.test.ts +83 -0
- package/src/__tests__/customTemplatesFrontmatter.test.ts +205 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +118 -0
- package/src/__tests__/diagramBlock.test.ts +122 -0
- package/src/__tests__/diagramLayout.test.ts +134 -0
- package/src/__tests__/drawing.test.ts +109 -0
- package/src/__tests__/drawingBlock.test.ts +124 -0
- package/src/__tests__/drawingLayout.test.ts +171 -0
- package/src/__tests__/geohash.test.ts +6 -5
- package/src/__tests__/getLayers.test.ts +6 -2
- package/src/__tests__/getLayersFallback.test.ts +103 -0
- package/src/__tests__/imageEditExportSvg.test.ts +70 -0
- package/src/__tests__/imageEditVersions.test.ts +72 -0
- package/src/__tests__/inlineIconMarker.test.ts +36 -0
- package/src/__tests__/layoutBlock.test.ts +83 -0
- package/src/__tests__/layoutLayout.test.ts +169 -0
- package/src/__tests__/markdown.test.ts +23 -0
- package/src/__tests__/markdownSanitize.test.ts +64 -0
- package/src/__tests__/markdownToDoc.test.ts +223 -2
- package/src/__tests__/mediaAnnotations.test.ts +64 -0
- package/src/__tests__/mediaSchedule.test.ts +118 -0
- package/src/__tests__/pandocAttrParse.test.ts +187 -0
- package/src/__tests__/resolveDocTheme.test.ts +55 -0
- package/src/__tests__/rootExports.test.ts +43 -0
- package/src/__tests__/shapeGeometry.test.ts +89 -0
- package/src/__tests__/shapeVocabulary.test.ts +133 -0
- package/src/__tests__/structuredData.test.ts +179 -0
- package/src/__tests__/templateAnnotationParse.test.ts +108 -0
- package/src/__tests__/templateMetadata.test.ts +21 -0
- package/src/__tests__/templates.test.ts +145 -4
- package/src/__tests__/themeCompile.test.ts +42 -0
- package/src/__tests__/themeValidator.test.ts +6 -0
- package/src/__tests__/transformV2.test.ts +137 -0
- package/src/__tests__/transitionNormalize.test.ts +61 -0
- package/src/__tests__/validateDoc.test.ts +100 -0
- package/src/doc/customTemplatesFrontmatter.ts +308 -0
- package/src/doc/customThemesFrontmatter.ts +90 -0
- package/src/doc/docToMarkdown.ts +73 -4
- package/src/doc/getLayers.ts +40 -17
- package/src/doc/index.ts +35 -1
- package/src/doc/markdownToDoc.ts +333 -12
- package/src/doc/mediaAnnotations.ts +118 -0
- package/src/doc/resolveDocTheme.ts +55 -0
- package/src/doc/structuredData.ts +230 -0
- package/src/doc/templateInputs.ts +240 -0
- package/src/doc/templates/__tests__/customTemplate.test.ts +151 -0
- package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
- package/src/doc/templates/accentImage.ts +30 -6
- package/src/doc/templates/captionUtils.ts +23 -0
- package/src/doc/templates/comparisonBar.ts +26 -18
- package/src/doc/templates/coverBlock.ts +7 -4
- package/src/doc/templates/customTemplate.ts +45 -0
- package/src/doc/templates/dataTable.ts +37 -19
- package/src/doc/templates/dateEvent.ts +41 -20
- package/src/doc/templates/definitionCard.ts +26 -18
- package/src/doc/templates/diagramBlock.ts +204 -0
- package/src/doc/templates/diagramLayout.ts +159 -0
- package/src/doc/templates/drawingBlock.ts +325 -0
- package/src/doc/templates/drawingLayout.ts +539 -0
- package/src/doc/templates/factCard.ts +51 -25
- package/src/doc/templates/fallbackBlock.ts +123 -0
- package/src/doc/templates/featureBlock.ts +20 -13
- package/src/doc/templates/fullBleedQuote.ts +18 -8
- package/src/doc/templates/imageWithCaption.ts +22 -9
- package/src/doc/templates/index.ts +215 -46
- package/src/doc/templates/layoutBlock.ts +43 -0
- package/src/doc/templates/layoutLayout.ts +243 -0
- package/src/doc/templates/listBlock.ts +56 -30
- package/src/doc/templates/mapBlock.ts +15 -12
- package/src/doc/templates/metadata.ts +120 -0
- package/src/doc/templates/persistentLayers.ts +129 -11
- package/src/doc/templates/photoGrid.ts +23 -12
- package/src/doc/templates/pullQuote.ts +30 -12
- package/src/doc/templates/quoteBlock.ts +54 -29
- package/src/doc/templates/sectionHeader.ts +25 -11
- package/src/doc/templates/statHighlight.ts +39 -22
- package/src/doc/templates/titleBlock.ts +59 -21
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +221 -0
- package/src/doc/templates/tokens/resolveTokens.ts +0 -0
- package/src/doc/templates/twoColumn.ts +48 -30
- package/src/doc/templates/videoPullQuote.ts +22 -12
- package/src/doc/templates/videoWithCaption.ts +13 -8
- package/src/doc/utils/animationUtils.ts +267 -3
- package/src/doc/utils/applyRenderStyle.ts +113 -0
- package/src/doc/utils/imageTreatment.ts +70 -0
- package/src/doc/utils/shapeGeometry.ts +464 -0
- package/src/doc/utils/themeUtils.ts +75 -1
- package/src/doc/validate.ts +476 -0
- package/src/generate/slideshowGenerator.ts +7 -0
- package/src/icons/index.ts +8 -0
- package/src/icons/inlineIconMarker.ts +73 -0
- package/src/imageEdit/export.ts +85 -2
- package/src/imageEdit/index.ts +1 -0
- package/src/imageEdit/persistence.ts +51 -0
- package/src/index.ts +2 -0
- package/src/markdown/annotationCoercion.ts +295 -0
- package/src/markdown/attrTokens.ts +222 -0
- package/src/markdown/convert.ts +229 -26
- package/src/markdown/index.ts +36 -0
- package/src/markdown/sanitize.ts +298 -0
- package/src/markdown/stringify.ts +84 -3
- package/src/markdown/types.ts +66 -0
- package/src/markdown/utils.ts +49 -1
- package/src/recommend/templates.ts +24 -0
- package/src/schemas/BlockTemplates.ts +133 -4
- package/src/schemas/CustomTemplates.ts +74 -0
- package/src/schemas/Doc.ts +353 -29
- package/src/schemas/ImageEditDoc.ts +11 -4
- package/src/schemas/Media.ts +169 -0
- package/src/schemas/Theme.ts +23 -1
- package/src/schemas/Transitions.ts +224 -0
- package/src/schemas/colorUtils.ts +33 -0
- package/src/schemas/fontStacks.ts +5 -2
- package/src/schemas/index.ts +3 -0
- package/src/schemas/themeCompile.ts +23 -4
- package/src/schemas/themeValidator.ts +49 -11
- package/src/schemas/themes/cinematic.json +69 -12
- package/src/schemas/themes/documentary.json +58 -11
- package/src/schemas/themes/gezellig.json +60 -11
- package/src/schemas/themes/magazine.json +54 -9
- package/src/schemas/themes/morning-light.json +52 -9
- package/src/schemas/themes/standard-dark.json +48 -13
- package/src/schemas/themes/standard.json +44 -12
- package/src/schemas/themes/tech-dark.json +65 -12
- package/src/spatial/Geohash.ts +52 -21
- package/src/transform/applyTransform.ts +43 -4
- package/src/transform/index.ts +1 -0
- package/src/transform/registry.ts +44 -13
- package/src/transform/styles/dataDriven.ts +2 -1
- package/src/transform/styles/documentary.ts +2 -1
- package/src/transform/styles/magazine.ts +3 -1
- package/src/transform/styles/minimal.ts +2 -1
- package/src/transform/styles/narrative.ts +3 -1
- package/src/transform/templateSelector.ts +87 -11
- package/src/transform/types.ts +35 -2
- package/dist/chunk-3K5OG6KI.js.map +0 -1
- package/dist/chunk-57CVQCFX.js.map +0 -1
- package/dist/chunk-5WFRKURX.js.map +0 -1
- package/dist/chunk-7UDSRZKG.js.map +0 -1
- package/dist/chunk-B4EEON3N.js.map +0 -1
- package/dist/chunk-H3AXU4MJ.js.map +0 -1
- package/dist/chunk-KSWJR66U.js +0 -3706
- package/dist/chunk-KSWJR66U.js.map +0 -1
- package/dist/chunk-MYIH7FWD.js.map +0 -1
- package/dist/chunk-ZTX4PFFT.js.map +0 -1
|
@@ -4,6 +4,13 @@ import type { MarkdownText } from '../markdown/types';
|
|
|
4
4
|
import { markdownToDoc, flattenBlocks, countBlocks, getBlockDepth } from '../doc/markdownToDoc';
|
|
5
5
|
import { docToMarkdown } from '../doc/docToMarkdown';
|
|
6
6
|
|
|
7
|
+
// Helper: parse markdown and return the first top-level block.
|
|
8
|
+
function firstHeadingBlock(md: string) {
|
|
9
|
+
const parsed = parseMarkdown(md);
|
|
10
|
+
const doc = markdownToDoc(parsed, { articleId: 'test', generateCoverBlock: false });
|
|
11
|
+
return doc.blocks[0];
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
// Helper: strip positions from markdown nodes for cleaner assertions
|
|
8
15
|
function stripPositions(obj: unknown): unknown {
|
|
9
16
|
if (obj === null || obj === undefined) return obj;
|
|
@@ -362,6 +369,16 @@ describe('template annotation in markdownToDoc', () => {
|
|
|
362
369
|
expect(output).toContain('{[factCard style=minimal]}');
|
|
363
370
|
});
|
|
364
371
|
|
|
372
|
+
it('docToMarkdown injects transition attributes from block metadata', () => {
|
|
373
|
+
const md = parseMarkdown('## Section\n\nBody');
|
|
374
|
+
const doc = markdownToDoc(md);
|
|
375
|
+
doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
|
|
376
|
+
|
|
377
|
+
const output = stringifyMarkdown(docToMarkdown(doc));
|
|
378
|
+
|
|
379
|
+
expect(output).toContain('{transition=wipe transitionDuration=0.8 transitionDirection=left}');
|
|
380
|
+
});
|
|
381
|
+
|
|
365
382
|
it('nested headings preserve their own annotations', () => {
|
|
366
383
|
const input = '# Chapter {[title]}\n\nIntro\n\n## Section {[chart]}\n\nData';
|
|
367
384
|
const md = parseMarkdown(input);
|
|
@@ -448,12 +465,16 @@ describe('caption generation', () => {
|
|
|
448
465
|
expect(doc.captions).toBeUndefined();
|
|
449
466
|
});
|
|
450
467
|
|
|
451
|
-
it('generates captions version
|
|
468
|
+
it('generates captions version; timestamp only when supplied (deterministic default)', () => {
|
|
452
469
|
const md = parseMarkdown('# Title\n\nHello world and more text.');
|
|
453
470
|
const doc = markdownToDoc(md);
|
|
454
471
|
|
|
455
472
|
expect(doc.captions!.version).toBe(1);
|
|
456
|
-
|
|
473
|
+
// No clock reads during conversion — generatedAt is opt-in.
|
|
474
|
+
expect(doc.captions!.generatedAt).toBeUndefined();
|
|
475
|
+
|
|
476
|
+
const stamped = markdownToDoc(md, { captionsGeneratedAt: '2026-01-01T00:00:00.000Z' });
|
|
477
|
+
expect(stamped.captions!.generatedAt).toBe('2026-01-01T00:00:00.000Z');
|
|
457
478
|
});
|
|
458
479
|
|
|
459
480
|
it('caption phrases have sequential non-overlapping times', () => {
|
|
@@ -563,3 +584,203 @@ describe('auto cover block generation', () => {
|
|
|
563
584
|
expect(doc.startBlock!.subtitle).toBeUndefined();
|
|
564
585
|
});
|
|
565
586
|
});
|
|
587
|
+
|
|
588
|
+
describe('markdownToDoc with Pandoc attributes', () => {
|
|
589
|
+
it('overrides block id from {#id}', () => {
|
|
590
|
+
const block = firstHeadingBlock('## Section title {#intro}');
|
|
591
|
+
expect(block.id).toBe('intro');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('applies x / y to typed Block fields', () => {
|
|
595
|
+
// `diagramNode` is a legacy alias for the canonical `diagram` template;
|
|
596
|
+
// the alias resolves at template-resolution time.
|
|
597
|
+
const block = firstHeadingBlock('## Step 1 {#step1 x=400 y=200} {[diagramNode]}');
|
|
598
|
+
expect(block.id).toBe('step1');
|
|
599
|
+
expect(block.x).toBe(400);
|
|
600
|
+
expect(block.y).toBe(200);
|
|
601
|
+
expect(block.template).toBe('diagram');
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('applies multiple connectsTo entries with mixed types', () => {
|
|
605
|
+
const block = firstHeadingBlock(
|
|
606
|
+
'## Architecture {#arch x=600 y=300 connectsTo=foo:veryImportant,bar:requires,baz:requires} {[diagramNode]}',
|
|
607
|
+
);
|
|
608
|
+
expect(block.connectsTo).toEqual([
|
|
609
|
+
{ target: 'foo', type: 'veryImportant' },
|
|
610
|
+
{ target: 'bar', type: 'requires' },
|
|
611
|
+
{ target: 'baz', type: 'requires' },
|
|
612
|
+
]);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('parses connections without types', () => {
|
|
616
|
+
const block = firstHeadingBlock('## Summary {#summary connectsTo=intro,middle,end}');
|
|
617
|
+
expect(block.connectsTo).toEqual([
|
|
618
|
+
{ target: 'intro' },
|
|
619
|
+
{ target: 'middle' },
|
|
620
|
+
{ target: 'end' },
|
|
621
|
+
]);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('parses mix of typed and untyped connections in one list', () => {
|
|
625
|
+
const block = firstHeadingBlock(
|
|
626
|
+
'## Decision {#decision connectsTo=alt1,alt2:rejected,alt3:chosen}',
|
|
627
|
+
);
|
|
628
|
+
expect(block.connectsTo).toEqual([
|
|
629
|
+
{ target: 'alt1' },
|
|
630
|
+
{ target: 'alt2', type: 'rejected' },
|
|
631
|
+
{ target: 'alt3', type: 'chosen' },
|
|
632
|
+
]);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('overrides startTime / duration with attribute values (seconds)', () => {
|
|
636
|
+
const block = firstHeadingBlock('## Closing {#closing startTime=02:30 duration=00:45}');
|
|
637
|
+
expect(block.startTime).toBe(150);
|
|
638
|
+
expect(block.duration).toBe(45);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('sets transition metadata from Pandoc attributes', () => {
|
|
642
|
+
const block = firstHeadingBlock(
|
|
643
|
+
'## Closing {#closing transition=checkerboard transitionDuration=1.2 transitionDirection=vertical}',
|
|
644
|
+
);
|
|
645
|
+
expect(block.transition).toEqual({
|
|
646
|
+
type: 'checkerboard',
|
|
647
|
+
duration: 1.2,
|
|
648
|
+
direction: 'vertical',
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('puts unknown keys into block.metadata', () => {
|
|
653
|
+
const block = firstHeadingBlock('## X {#x priority=high status=draft}');
|
|
654
|
+
expect(block.metadata).toEqual({ priority: 'high', status: 'draft' });
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('routes template params separately from block-level metadata', () => {
|
|
658
|
+
const block = firstHeadingBlock(
|
|
659
|
+
'## X {#big x=400 priority=high} {[diagramNode colorScheme=blue]}',
|
|
660
|
+
);
|
|
661
|
+
expect(block.templateOverrides).toEqual({ colorScheme: 'blue' });
|
|
662
|
+
expect(block.metadata).toEqual({ priority: 'high' });
|
|
663
|
+
expect(block.x).toBe(400);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('stores Pandoc classes on block.classes', () => {
|
|
667
|
+
const block = firstHeadingBlock('## X {#x .alpha .beta}');
|
|
668
|
+
expect(block.classes).toEqual(['alpha', 'beta']);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('backward compat: plain {[…]}-only heading is unchanged', () => {
|
|
672
|
+
const block = firstHeadingBlock('## X {[chart colorScheme=blue]}');
|
|
673
|
+
expect(block.template).toBe('chart');
|
|
674
|
+
expect(block.templateOverrides).toEqual({ colorScheme: 'blue' });
|
|
675
|
+
expect(block.x).toBeUndefined();
|
|
676
|
+
expect(block.metadata).toBeUndefined();
|
|
677
|
+
expect(block.classes).toBeUndefined();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe('markdownToDoc block-meta in the squiggly {[…]} form', () => {
|
|
682
|
+
it('reads duration from a pure key-value {[duration=N]} annotation', () => {
|
|
683
|
+
const block = firstHeadingBlock('## Closing {[duration=8]}\n\nSome body text here.\n');
|
|
684
|
+
expect(block.duration).toBe(8);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('does not let reading-time overwrite a squiggly-pinned duration', () => {
|
|
688
|
+
// Body long enough that the reading-time estimate would exceed 8s if it ran.
|
|
689
|
+
const body = Array(80).fill('word').join(' ');
|
|
690
|
+
const block = firstHeadingBlock(`## Closing {[duration=8]}\n\n${body}\n`);
|
|
691
|
+
expect(block.duration).toBe(8);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('folds duration alongside a template name', () => {
|
|
695
|
+
const block = firstHeadingBlock('## Hero {[sectionHeader duration=6]}');
|
|
696
|
+
expect(block.template).toBe('sectionHeader');
|
|
697
|
+
expect(block.duration).toBe(6);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('folds transition alongside a template name', () => {
|
|
701
|
+
const block = firstHeadingBlock(
|
|
702
|
+
'## Hero {[sectionHeader transition=fly-through transitionDuration=750ms]}',
|
|
703
|
+
);
|
|
704
|
+
expect(block.template).toBe('sectionHeader');
|
|
705
|
+
expect(block.transition).toEqual({
|
|
706
|
+
type: 'flyThrough',
|
|
707
|
+
duration: 0.75,
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('accepts startTime in the squiggly form too', () => {
|
|
712
|
+
const block = firstHeadingBlock('## Closing {[startTime=02:30 duration=45]}');
|
|
713
|
+
expect(block.startTime).toBe(150);
|
|
714
|
+
expect(block.duration).toBe(45);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('Pandoc attrs win when a key appears in both forms', () => {
|
|
718
|
+
const block = firstHeadingBlock('## X {duration=3} {[duration=9]}');
|
|
719
|
+
expect(block.duration).toBe(3);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
describe('auto template picking (autoTemplates, default on)', () => {
|
|
724
|
+
const toDocFromMd = (md: string, options?: Parameters<typeof markdownToDoc>[1]) =>
|
|
725
|
+
markdownToDoc(parseMarkdown(md), options);
|
|
726
|
+
|
|
727
|
+
it('picks quote for a blockquote-bearing section and derives the quote text', () => {
|
|
728
|
+
const doc = toDocFromMd('# Wisdom\n\n> The best template feels obvious.\n');
|
|
729
|
+
const block = doc.blocks[0];
|
|
730
|
+
expect(block.template).toBe('quote');
|
|
731
|
+
expect(block.autoTemplate).toBe(true);
|
|
732
|
+
expect(block.templateData?.quote).toBe('The best template feels obvious.');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('picks dataTable for a table-bearing section with derived headers/rows', () => {
|
|
736
|
+
const doc = toDocFromMd('# Results\n\n| A | B |\n| - | - |\n| 1 | 2 |\n');
|
|
737
|
+
const block = doc.blocks[0];
|
|
738
|
+
expect(block.template).toBe('dataTable');
|
|
739
|
+
expect(block.templateData?.headers).toEqual(['A', 'B']);
|
|
740
|
+
expect(block.templateData?.rows).toEqual([['1', '2']]);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('picks list for list-bearing sections and photoGrid for multi-image sections', () => {
|
|
744
|
+
const doc = toDocFromMd('# Steps\n\n- one\n- two\n\n# Gallery\n\n\n\n\n');
|
|
745
|
+
expect(doc.blocks[0].template).toBe('list');
|
|
746
|
+
expect(doc.blocks[0].templateData?.items).toEqual(['one', 'two']);
|
|
747
|
+
expect(doc.blocks[1].template).toBe('photoGrid');
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('alternates left/right feature for consecutive single-image sections', () => {
|
|
751
|
+
const doc = toDocFromMd('# One\n\n\n\n# Two\n\n\n');
|
|
752
|
+
expect(doc.blocks[0].template).toBe('leftFeature');
|
|
753
|
+
expect(doc.blocks[1].template).toBe('rightFeature');
|
|
754
|
+
expect(doc.blocks[0].templateData?.imageSrc).toBe('a.jpg');
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('leaves plain prose sections on the structural default', () => {
|
|
758
|
+
const doc = toDocFromMd('# About\n\nJust a paragraph of prose.\n');
|
|
759
|
+
expect(doc.blocks[0].template).toBe('sectionHeader');
|
|
760
|
+
expect(doc.blocks[0].autoTemplate).toBeUndefined();
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('never overrides an explicit annotation', () => {
|
|
764
|
+
const doc = toDocFromMd('# Wisdom {[factCard fact="F"]}\n\n> quoted\n');
|
|
765
|
+
expect(doc.blocks[0].template).toBe('factCard');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('is disabled by the option and by frontmatter', () => {
|
|
769
|
+
const md = '# Wisdom\n\n> quoted\n';
|
|
770
|
+
const viaOption = toDocFromMd(md, { autoTemplates: false });
|
|
771
|
+
expect(viaOption.blocks[0].template).toBe('sectionHeader');
|
|
772
|
+
|
|
773
|
+
const viaFrontmatter = markdownToDoc(
|
|
774
|
+
parseMarkdown('---\nsquisq-auto-templates: false\n---\n\n' + md),
|
|
775
|
+
);
|
|
776
|
+
expect(viaFrontmatter.blocks[0].template).toBe('sectionHeader');
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('keeps the round-trip lossless (no materialized annotations)', () => {
|
|
780
|
+
const md = '# Wisdom\n\n> The best template feels obvious.\n';
|
|
781
|
+
const doc = toDocFromMd(md);
|
|
782
|
+
expect(doc.blocks[0].template).toBe('quote');
|
|
783
|
+
const roundTripped = stringifyMarkdown(docToMarkdown(doc));
|
|
784
|
+
expect(roundTripped).not.toContain('{[quote');
|
|
785
|
+
});
|
|
786
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseMarkdown } from '../markdown/parse.js';
|
|
3
|
+
import { markdownToDoc } from '../doc/markdownToDoc.js';
|
|
4
|
+
import { resolveMediaSchedule } from '../schemas/Media.js';
|
|
5
|
+
|
|
6
|
+
function toDoc(md: string) {
|
|
7
|
+
return markdownToDoc(parseMarkdown(md), { articleId: 't' });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('markdownToDoc media annotations', () => {
|
|
11
|
+
it('lifts a block-level audio annotation into block.media', () => {
|
|
12
|
+
const doc = toDoc(
|
|
13
|
+
'# One {duration=10}\n\nIntro.\n\n{[audio src=narration.mp3 startAt=5 clipEnd=8 spillover=true]}\n',
|
|
14
|
+
);
|
|
15
|
+
const block = doc.blocks[0];
|
|
16
|
+
expect(block.media).toHaveLength(1);
|
|
17
|
+
expect(block.media![0]).toMatchObject({
|
|
18
|
+
src: 'narration.mp3',
|
|
19
|
+
kind: 'audio',
|
|
20
|
+
startAt: 5,
|
|
21
|
+
clipEnd: 8,
|
|
22
|
+
spillover: true,
|
|
23
|
+
anchor: 'block',
|
|
24
|
+
});
|
|
25
|
+
// The annotation paragraph is removed from the visible contents.
|
|
26
|
+
expect(JSON.stringify(block.contents)).not.toContain('narration.mp3');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('routes anchor=document annotations to doc.documentMedia', () => {
|
|
30
|
+
const doc = toDoc('{[audio src=voice.mp3 anchor=document]}\n\n# First\n\nBody.\n');
|
|
31
|
+
expect(doc.documentMedia).toHaveLength(1);
|
|
32
|
+
expect(doc.documentMedia![0]).toMatchObject({ src: 'voice.mp3', anchor: 'document' });
|
|
33
|
+
// It is not also attached to the preamble block's media.
|
|
34
|
+
expect(doc.blocks[0].media).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('recognizes a video annotation as kind video', () => {
|
|
38
|
+
const doc = toDoc('# B {duration=20}\n\n{[video src=clip.mp4 clipStart=2 clipEnd=8]}\n');
|
|
39
|
+
expect(doc.blocks[0].media![0]).toMatchObject({
|
|
40
|
+
src: 'clip.mp4',
|
|
41
|
+
kind: 'video',
|
|
42
|
+
clipStart: 2,
|
|
43
|
+
clipEnd: 8,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('media annotation text does not inflate block duration', () => {
|
|
48
|
+
// No explicit duration; body is only the annotation → default (5s), not reading-time of the annotation text.
|
|
49
|
+
const doc = toDoc('# B\n\n{[audio src=a.mp3]}\n');
|
|
50
|
+
expect(doc.blocks[0].duration).toBe(5);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('feeds resolveMediaSchedule end to end', () => {
|
|
54
|
+
const doc = toDoc(
|
|
55
|
+
'{[audio src=voice.mp3 anchor=document]}\n\n# One {duration=30}\n\n# Two {duration=20}\n\n{[audio src=a.mp3 startAt=5 clipEnd=8]}\n',
|
|
56
|
+
);
|
|
57
|
+
const sched = resolveMediaSchedule(doc);
|
|
58
|
+
const doc1 = sched.find((c) => c.anchor === 'document')!;
|
|
59
|
+
const block = sched.find((c) => c.anchor === 'block')!;
|
|
60
|
+
expect(doc1.absoluteStart).toBe(0);
|
|
61
|
+
expect(doc1.absoluteEnd).toBe(50); // spans both blocks
|
|
62
|
+
expect(block.absoluteStart).toBe(35); // block Two at 30 + startAt 5
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveMediaSchedule, getDocPlaybackDuration } from '../schemas/Media.js';
|
|
3
|
+
import type { Doc, Block } from '../schemas/Doc.js';
|
|
4
|
+
|
|
5
|
+
function block(partial: Partial<Block>): Block {
|
|
6
|
+
return {
|
|
7
|
+
id: partial.id ?? 'b',
|
|
8
|
+
startTime: partial.startTime ?? 0,
|
|
9
|
+
duration: partial.duration ?? 10,
|
|
10
|
+
audioSegment: 0,
|
|
11
|
+
...partial,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function doc(blocks: Block[], extra?: Partial<Doc>): Doc {
|
|
16
|
+
const lastEnd = blocks.reduce((m, b) => Math.max(m, b.startTime + b.duration), 0);
|
|
17
|
+
return { articleId: 't', duration: lastEnd, blocks, audio: { segments: [] }, ...extra };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('resolveMediaSchedule', () => {
|
|
21
|
+
it('offsets a block clip by startAt relative to the block start', () => {
|
|
22
|
+
// Block #2 starts at 30s; a clip with startAt=5 plays at 35s.
|
|
23
|
+
const d = doc([
|
|
24
|
+
block({ id: 'one', startTime: 0, duration: 30 }),
|
|
25
|
+
block({
|
|
26
|
+
id: 'two',
|
|
27
|
+
startTime: 30,
|
|
28
|
+
duration: 20,
|
|
29
|
+
media: [{ id: 'm1', src: 'a.mp3', kind: 'audio', startAt: 5, clipEnd: 8, anchor: 'block' }],
|
|
30
|
+
}),
|
|
31
|
+
]);
|
|
32
|
+
const [clip] = resolveMediaSchedule(d);
|
|
33
|
+
expect(clip.absoluteStart).toBe(35);
|
|
34
|
+
expect(clip.absoluteEnd).toBe(43); // 35 + (clipEnd 8 - clipStart 0)
|
|
35
|
+
expect(clip.sourceIn).toBe(0);
|
|
36
|
+
expect(clip.blockId).toBe('two');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('clamps a non-spillover clip to the block end', () => {
|
|
40
|
+
const d = doc([
|
|
41
|
+
block({
|
|
42
|
+
id: 'b',
|
|
43
|
+
startTime: 0,
|
|
44
|
+
duration: 10,
|
|
45
|
+
media: [
|
|
46
|
+
{ id: 'm', src: 'a.mp3', kind: 'audio', startAt: 2, clipEnd: 100, anchor: 'block' },
|
|
47
|
+
],
|
|
48
|
+
}),
|
|
49
|
+
]);
|
|
50
|
+
const [clip] = resolveMediaSchedule(d);
|
|
51
|
+
expect(clip.absoluteEnd).toBe(10); // clamped to block end, not 2 + 100
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('lets a spillover clip run past the block end', () => {
|
|
55
|
+
const d = doc([
|
|
56
|
+
block({
|
|
57
|
+
id: 'b',
|
|
58
|
+
startTime: 0,
|
|
59
|
+
duration: 10,
|
|
60
|
+
media: [
|
|
61
|
+
{
|
|
62
|
+
id: 'm',
|
|
63
|
+
src: 'a.mp3',
|
|
64
|
+
kind: 'audio',
|
|
65
|
+
startAt: 2,
|
|
66
|
+
clipEnd: 100,
|
|
67
|
+
spillover: true,
|
|
68
|
+
anchor: 'block',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
block({ id: 'b2', startTime: 10, duration: 10 }),
|
|
73
|
+
]);
|
|
74
|
+
const [clip] = resolveMediaSchedule(d);
|
|
75
|
+
expect(clip.absoluteEnd).toBe(102); // 2 + 100, past the block end
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('spans the whole document for an anchor=document clip', () => {
|
|
79
|
+
const d = doc([block({ id: 'b', startTime: 0, duration: 40 })], {
|
|
80
|
+
documentMedia: [
|
|
81
|
+
{ id: 'narr', src: 'voice.mp3', kind: 'audio', startAt: 0, anchor: 'document' },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
const [clip] = resolveMediaSchedule(d);
|
|
85
|
+
expect(clip.absoluteStart).toBe(0);
|
|
86
|
+
expect(clip.absoluteEnd).toBe(40);
|
|
87
|
+
expect(clip.anchor).toBe('document');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('getDocPlaybackDuration', () => {
|
|
92
|
+
it('extends total duration to include media spillover', () => {
|
|
93
|
+
const d = doc([
|
|
94
|
+
block({
|
|
95
|
+
id: 'b',
|
|
96
|
+
startTime: 0,
|
|
97
|
+
duration: 10,
|
|
98
|
+
media: [
|
|
99
|
+
{
|
|
100
|
+
id: 'm',
|
|
101
|
+
src: 'a.mp3',
|
|
102
|
+
kind: 'audio',
|
|
103
|
+
startAt: 0,
|
|
104
|
+
clipEnd: 25,
|
|
105
|
+
spillover: true,
|
|
106
|
+
anchor: 'block',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
]);
|
|
111
|
+
expect(getDocPlaybackDuration(d)).toBe(25);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('falls back to the block timeline when there is no media', () => {
|
|
115
|
+
const d = doc([block({ id: 'b', startTime: 0, duration: 12 })]);
|
|
116
|
+
expect(getDocPlaybackDuration(d)).toBe(12);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseMarkdown, stringifyMarkdown } from '../markdown/index.js';
|
|
3
|
+
|
|
4
|
+
function getHeading(md: string) {
|
|
5
|
+
const doc = parseMarkdown(md);
|
|
6
|
+
const heading = doc.children[0];
|
|
7
|
+
if (heading.type !== 'heading') throw new Error('expected heading');
|
|
8
|
+
return heading;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('Pandoc heading attribute parse', () => {
|
|
12
|
+
it('extracts an id-only block', () => {
|
|
13
|
+
const h = getHeading('## Section title {#intro}');
|
|
14
|
+
expect(h.attributes).toEqual({ id: 'intro' });
|
|
15
|
+
expect(h.templateAnnotation).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('extracts classes', () => {
|
|
19
|
+
const h = getHeading('## X {.alpha .beta}');
|
|
20
|
+
expect(h.attributes?.classes).toEqual(['alpha', 'beta']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('extracts id + classes + params', () => {
|
|
24
|
+
const h = getHeading('## X {#abc .v1 .v2 x=400 y=200}');
|
|
25
|
+
expect(h.attributes?.id).toBe('abc');
|
|
26
|
+
expect(h.attributes?.classes).toEqual(['v1', 'v2']);
|
|
27
|
+
expect(h.attributes?.params).toEqual({ x: '400', y: '200' });
|
|
28
|
+
expect(h.attributes?.blockMeta).toEqual({ x: 400, y: 200 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('coerces connectsTo to a typed list', () => {
|
|
32
|
+
const h = getHeading('## X {connectsTo=foo:veryImportant,bar:requires,baz:requires}');
|
|
33
|
+
expect(h.attributes?.blockMeta?.connectsTo).toEqual([
|
|
34
|
+
{ target: 'foo', type: 'veryImportant' },
|
|
35
|
+
{ target: 'bar', type: 'requires' },
|
|
36
|
+
{ target: 'baz', type: 'requires' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('coerces startTime/duration to seconds', () => {
|
|
41
|
+
const h = getHeading('## X {startTime=01:30 duration=00:45}');
|
|
42
|
+
expect(h.attributes?.blockMeta?.startTime).toBe(90);
|
|
43
|
+
expect(h.attributes?.blockMeta?.duration).toBe(45);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('routes unknown keys to metadata', () => {
|
|
47
|
+
const h = getHeading('## X {#a priority=high level=info}');
|
|
48
|
+
expect(h.attributes?.metadata).toEqual({ priority: 'high', level: 'info' });
|
|
49
|
+
expect(h.attributes?.blockMeta).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('handles quoted values with spaces', () => {
|
|
53
|
+
const h = getHeading('## X {caption="hello world"}');
|
|
54
|
+
expect(h.attributes?.params).toEqual({ caption: 'hello world' });
|
|
55
|
+
expect(h.attributes?.metadata).toEqual({ caption: 'hello world' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('preserves commas inside quoted values', () => {
|
|
59
|
+
const h = getHeading('## X {caption="hello, friend"}');
|
|
60
|
+
expect(h.attributes?.metadata).toEqual({ caption: 'hello, friend' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('escaped quotes inside quoted values', () => {
|
|
64
|
+
const h = getHeading('## X {label="he said \\"hi\\""}');
|
|
65
|
+
expect(h.attributes?.metadata?.label).toBe('he said "hi"');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles single-quoted values with spaces', () => {
|
|
69
|
+
const h = getHeading("## X {caption='hello world'}");
|
|
70
|
+
expect(h.attributes?.params).toEqual({ caption: 'hello world' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('allows } inside a quoted value', () => {
|
|
74
|
+
const h = getHeading('## X {caption="a } b"}');
|
|
75
|
+
expect(h.attributes?.params).toEqual({ caption: 'a } b' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('treats an apostrophe inside an unquoted value as literal', () => {
|
|
79
|
+
const h = getHeading("## X {name=O'Brien x=4}");
|
|
80
|
+
expect(h.attributes?.metadata).toEqual({ name: "O'Brien" });
|
|
81
|
+
expect(h.attributes?.blockMeta?.x).toBe(4);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('duplicate id: last wins', () => {
|
|
85
|
+
const h = getHeading('## X {#a #b}');
|
|
86
|
+
expect(h.attributes?.id).toBe('b');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('duplicate key: last wins', () => {
|
|
90
|
+
const h = getHeading('## X {x=1 x=2}');
|
|
91
|
+
expect(h.attributes?.blockMeta?.x).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does NOT match a non-trailing brace block', () => {
|
|
95
|
+
const h = getHeading('## The {#wrong} section');
|
|
96
|
+
expect(h.attributes).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does NOT collide with {[…]} template annotation', () => {
|
|
100
|
+
const h = getHeading('## X {[chart]}');
|
|
101
|
+
expect(h.templateAnnotation).toEqual({ template: 'chart' });
|
|
102
|
+
expect(h.attributes).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('Coexistence of {#…} and {[…]}', () => {
|
|
107
|
+
it('Pandoc first, then template', () => {
|
|
108
|
+
const h = getHeading('## X {#abc} {[chart]}');
|
|
109
|
+
expect(h.attributes?.id).toBe('abc');
|
|
110
|
+
expect(h.templateAnnotation?.template).toBe('chart');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('Template first, then Pandoc (order-agnostic)', () => {
|
|
114
|
+
const h = getHeading('## X {[chart]} {#abc}');
|
|
115
|
+
expect(h.attributes?.id).toBe('abc');
|
|
116
|
+
expect(h.templateAnnotation?.template).toBe('chart');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('both with params on each side', () => {
|
|
120
|
+
const h = getHeading('## X {#a x=400 y=200} {[chart colorScheme=blue]}');
|
|
121
|
+
expect(h.attributes?.id).toBe('a');
|
|
122
|
+
expect(h.attributes?.blockMeta?.x).toBe(400);
|
|
123
|
+
expect(h.attributes?.blockMeta?.y).toBe(200);
|
|
124
|
+
expect(h.templateAnnotation?.template).toBe('chart');
|
|
125
|
+
expect(h.templateAnnotation?.params).toEqual({ colorScheme: 'blue' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('Pandoc block-meta does not leak into templateOverrides', () => {
|
|
129
|
+
const h = getHeading('## X {#a x=400} {[chart colorScheme=blue]}');
|
|
130
|
+
expect(h.templateAnnotation?.params).toEqual({ colorScheme: 'blue' });
|
|
131
|
+
expect(h.attributes?.params).toEqual({ x: '400' });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('Markdown round-trip with attributes', () => {
|
|
136
|
+
it('Pandoc-only id round-trips', () => {
|
|
137
|
+
const src = '## Section title {#intro}\n';
|
|
138
|
+
const md = parseMarkdown(src);
|
|
139
|
+
expect(stringifyMarkdown(md).trim()).toBe(src.trim());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('canonicalizes order: Pandoc first, then template', () => {
|
|
143
|
+
const input = '## X {[chart]} {#abc}\n';
|
|
144
|
+
const md = parseMarkdown(input);
|
|
145
|
+
const out = stringifyMarkdown(md).trim();
|
|
146
|
+
expect(out).toBe('## X {#abc} {[chart]}');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('full attribute set round-trips', () => {
|
|
150
|
+
const src =
|
|
151
|
+
'## Architecture {#arch x=600 y=300 connectsTo=foo:veryImportant,bar:requires,baz:requires} {[diagramNode]}\n';
|
|
152
|
+
const md = parseMarkdown(src);
|
|
153
|
+
const out = stringifyMarkdown(md).trim();
|
|
154
|
+
expect(out).toBe(src.trim());
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('quoted value round-trips with quoting preserved', () => {
|
|
158
|
+
const src = '## X {caption="hello world"}\n';
|
|
159
|
+
const md = parseMarkdown(src);
|
|
160
|
+
const out = stringifyMarkdown(md).trim();
|
|
161
|
+
expect(out).toBe(src.trim());
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('existing {[…]}-only docs are unchanged', () => {
|
|
165
|
+
const src = '## Getting Started {[chart colorScheme=blue]}\n';
|
|
166
|
+
const md = parseMarkdown(src);
|
|
167
|
+
const out = stringifyMarkdown(md).trim();
|
|
168
|
+
expect(out).toBe(src.trim());
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('single-quoted values canonicalize to double quotes', () => {
|
|
172
|
+
const md = parseMarkdown("## X {caption='hello world'}");
|
|
173
|
+
expect(stringifyMarkdown(md).trim()).toBe('## X {caption="hello world"}');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('a value containing a double quote round-trips via single quotes', () => {
|
|
177
|
+
const src = '## X {label=\'say "hi"\'}';
|
|
178
|
+
const md = parseMarkdown(src);
|
|
179
|
+
expect(stringifyMarkdown(md).trim()).toBe(src);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('a quoted } round-trips', () => {
|
|
183
|
+
const src = '## X {caption="a } b"}';
|
|
184
|
+
const md = parseMarkdown(src);
|
|
185
|
+
expect(stringifyMarkdown(md).trim()).toBe(src);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveThemeForDoc — pure, doc-scoped theme resolution.
|
|
3
|
+
*
|
|
4
|
+
* The theme analog of buildRegistry: an inline custom theme resolves from the
|
|
5
|
+
* doc's own `customThemes` list, with no global `registerTheme` call. Falls
|
|
6
|
+
* back to built-ins for non-inline ids.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { resolveThemeForDoc } from '../doc/resolveDocTheme';
|
|
11
|
+
import { compileTheme } from '../schemas/themeCompile';
|
|
12
|
+
import { DEFAULT_THEME } from '../schemas/themeLibrary';
|
|
13
|
+
import type { Doc } from '../schemas/Doc.js';
|
|
14
|
+
|
|
15
|
+
const brand = compileTheme({
|
|
16
|
+
id: 'my-brand',
|
|
17
|
+
name: 'My Brand',
|
|
18
|
+
seedColors: { primary: '#3182ce' },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function docWith(partial: Partial<Doc>): Doc {
|
|
22
|
+
return {
|
|
23
|
+
articleId: 'd',
|
|
24
|
+
duration: 0,
|
|
25
|
+
blocks: [],
|
|
26
|
+
audio: { segments: [] },
|
|
27
|
+
...partial,
|
|
28
|
+
} as Doc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('resolveThemeForDoc', () => {
|
|
32
|
+
it('resolves an inline custom theme doc-scoped (no global registration)', () => {
|
|
33
|
+
const doc = docWith({ customThemes: [brand], frontmatter: { 'squisq-theme': 'my-brand' } });
|
|
34
|
+
expect(resolveThemeForDoc(doc)).toEqual(brand);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('honors an explicit id over the frontmatter selection', () => {
|
|
38
|
+
const doc = docWith({ customThemes: [brand], frontmatter: { 'squisq-theme': 'cinematic' } });
|
|
39
|
+
expect(resolveThemeForDoc(doc, 'my-brand')).toEqual(brand);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to a built-in when the id is not an inline theme', () => {
|
|
43
|
+
const doc = docWith({ customThemes: [brand], frontmatter: { 'squisq-theme': 'cinematic' } });
|
|
44
|
+
expect(resolveThemeForDoc(doc).id).toBe('cinematic');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses Doc.themeId when there is no explicit or frontmatter id', () => {
|
|
48
|
+
const doc = docWith({ customThemes: [brand], themeId: 'my-brand' });
|
|
49
|
+
expect(resolveThemeForDoc(doc)).toEqual(brand);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns the default theme for a null doc', () => {
|
|
53
|
+
expect(resolveThemeForDoc(null).id).toBe(DEFAULT_THEME.id);
|
|
54
|
+
});
|
|
55
|
+
});
|