@bendyline/squisq 1.4.1 → 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-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
- package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.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-6ATE2PSM.js +344 -0
- package/dist/chunk-6ATE2PSM.js.map +1 -0
- package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
- package/dist/chunk-A4O7GIWE.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-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/chunk-HWVFJAAH.js +362 -0
- 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-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
- package/dist/chunk-KKNUBQ6Y.js.map +1 -0
- package/dist/chunk-LDQ2HJIX.js +596 -0
- package/dist/chunk-LDQ2HJIX.js.map +1 -0
- package/dist/chunk-LH7I6SH7.js +7208 -0
- package/dist/chunk-LH7I6SH7.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-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.js.map +1 -0
- package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
- 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-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
- package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
- package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
- package/dist/chunk-VWUFZ6ZG.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 +5 -5
- package/dist/doc/index.js +135 -8
- package/dist/generate/index.d.ts +3 -74
- package/dist/generate/index.js +2 -7
- 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 +12 -6
- package/dist/imageEdit/index.js +4 -1
- package/dist/index.d.ts +14 -11
- package/dist/index.js +265 -37
- package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
- package/dist/jsonForm/index.d.ts +42 -1
- package/dist/jsonForm/index.js +9 -1
- package/dist/markdown/index.d.ts +228 -3
- package/dist/markdown/index.js +42 -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 +36 -8
- package/dist/spatial/index.d.ts +9 -4
- package/dist/spatial/index.js +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +1 -1
- package/dist/story/index.d.ts +1021 -41
- package/dist/story/index.js +135 -8
- package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
- package/dist/timing/index.js +3 -5
- 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/dist/versions/index.d.ts +1 -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__/contentExtractor.test.ts +60 -0
- package/src/__tests__/coverBlock.test.ts +46 -1
- package/src/__tests__/customTemplateSample.test.ts +144 -0
- package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +145 -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__/inputDescriptors.test.ts +162 -0
- package/src/__tests__/jsonForm.tokens.test.ts +84 -0
- package/src/__tests__/layoutBlock.test.ts +83 -0
- package/src/__tests__/layoutLayout.test.ts +169 -0
- package/src/__tests__/markdown.test.ts +87 -0
- package/src/__tests__/markdownSanitize.test.ts +79 -0
- package/src/__tests__/markdownToDoc.test.ts +391 -2
- package/src/__tests__/mediaAnnotations.test.ts +91 -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 +249 -0
- package/src/__tests__/templateAnnotationParse.test.ts +108 -0
- package/src/__tests__/templateMetadata.test.ts +31 -0
- package/src/__tests__/templates.test.ts +257 -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 +260 -0
- package/src/doc/annotationBlocks.ts +117 -0
- package/src/doc/customTemplatesFrontmatter.ts +314 -0
- package/src/doc/customThemesFrontmatter.ts +95 -0
- package/src/doc/docToMarkdown.ts +104 -5
- package/src/doc/getLayers.ts +49 -17
- package/src/doc/index.ts +47 -1
- package/src/doc/markdownToDoc.ts +411 -13
- package/src/doc/mediaAnnotations.ts +100 -0
- package/src/doc/resolveDocTheme.ts +55 -0
- package/src/doc/standaloneAnnotation.ts +58 -0
- package/src/doc/structuredData.ts +301 -0
- package/src/doc/templateInputs.ts +240 -0
- package/src/doc/templates/__tests__/customTemplate.test.ts +170 -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 +33 -18
- package/src/doc/templates/coverBlock.ts +35 -5
- package/src/doc/templates/customTemplate.ts +51 -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 +54 -26
- 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 +30 -15
- package/src/doc/templates/index.ts +225 -46
- package/src/doc/templates/inputDescriptors.ts +432 -0
- 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 +22 -34
- 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 +487 -0
- package/src/doc/templates/tokens/resolveTokens.ts +453 -0
- package/src/doc/templates/twoColumn.ts +48 -30
- package/src/doc/templates/videoPullQuote.ts +22 -12
- package/src/doc/templates/videoWithCaption.ts +21 -15
- 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/nearest.ts +59 -0
- package/src/doc/utils/shapeGeometry.ts +464 -0
- package/src/doc/utils/themeUtils.ts +75 -1
- package/src/doc/validate.ts +575 -0
- package/src/generate/contentExtractor.ts +148 -35
- package/src/generate/index.ts +0 -3
- 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/jsonForm/index.ts +2 -0
- package/src/jsonForm/tokens.ts +78 -0
- package/src/markdown/annotationCoercion.ts +295 -0
- package/src/markdown/attrTokens.ts +241 -0
- package/src/markdown/convert.ts +229 -26
- package/src/markdown/index.ts +37 -0
- package/src/markdown/sanitize.ts +318 -0
- package/src/markdown/stringify.ts +96 -6
- package/src/markdown/types.ts +66 -0
- package/src/markdown/utils.ts +111 -2
- package/src/recommend/templates.ts +24 -0
- package/src/schemas/BlockTemplates.ts +133 -4
- package/src/schemas/CustomTemplates.ts +126 -0
- package/src/schemas/Doc.ts +387 -65
- 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 +24 -5
- 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/storage/ContentContainer.ts +6 -2
- 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-2TMAWB4Y.js.map +0 -1
- package/dist/chunk-3E5F2XMR.js.map +0 -1
- package/dist/chunk-3K5OG6KI.js.map +0 -1
- package/dist/chunk-4X3JQXNM.js +0 -35
- package/dist/chunk-4X3JQXNM.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-FR2RBTKO.js.map +0 -1
- package/dist/chunk-H3AXU4MJ.js +0 -179
- package/dist/chunk-H3AXU4MJ.js.map +0 -1
- package/dist/chunk-K32VJONL.js.map +0 -1
- package/dist/chunk-KSWJR66U.js +0 -3706
- package/dist/chunk-KSWJR66U.js.map +0 -1
- package/dist/chunk-MYIH7FWD.js +0 -145
- package/dist/chunk-MYIH7FWD.js.map +0 -1
- package/dist/chunk-ZTX4PFFT.js.map +0 -1
- package/src/__tests__/slideshowGenerator.test.ts +0 -128
- package/src/generate/slideshowGenerator.ts +0 -273
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
countNodes,
|
|
12
12
|
createDocument,
|
|
13
13
|
parseFrontmatter,
|
|
14
|
+
setFrontmatterValues,
|
|
15
|
+
plainTextFromInlineHtml,
|
|
14
16
|
} from '../markdown/index';
|
|
15
17
|
import type {
|
|
16
18
|
MarkdownDocument,
|
|
@@ -838,6 +840,69 @@ describe('parseFrontmatter', () => {
|
|
|
838
840
|
expect(parseFrontmatter('')).toBeNull();
|
|
839
841
|
expect(parseFrontmatter(' \n ')).toBeNull();
|
|
840
842
|
});
|
|
843
|
+
|
|
844
|
+
it('parses a `|-` literal block scalar, dedenting the body', () => {
|
|
845
|
+
const result = parseFrontmatter('data: |-\n line one\n line two\ntitle: After');
|
|
846
|
+
expect(result).toEqual({ data: 'line one\nline two', title: 'After' });
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('parses a `|` literal block scalar (same joined value here)', () => {
|
|
850
|
+
const result = parseFrontmatter('data: |\n a\n b');
|
|
851
|
+
expect(result).toEqual({ data: 'a\nb' });
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('ends the block scalar at a line indented to the key or less', () => {
|
|
855
|
+
const result = parseFrontmatter('config: |-\n {\n "k": 1\n }\nnext: x');
|
|
856
|
+
expect(result).toEqual({ config: '{\n "k": 1\n}', next: 'x' });
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('parses multi-line pretty JSON as a block scalar back to a JSON string', () => {
|
|
860
|
+
const json = JSON.stringify({ hero: { lb: 'Hero' } }, null, 2);
|
|
861
|
+
const body = json
|
|
862
|
+
.split('\n')
|
|
863
|
+
.map((l) => ` ${l}`)
|
|
864
|
+
.join('\n');
|
|
865
|
+
const result = parseFrontmatter(`squisq-custom-templates: |-\n${body}`);
|
|
866
|
+
expect(typeof result!['squisq-custom-templates']).toBe('string');
|
|
867
|
+
expect(JSON.parse(result!['squisq-custom-templates'] as string)).toEqual({
|
|
868
|
+
hero: { lb: 'Hero' },
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('leaves single-line values untouched alongside block scalars', () => {
|
|
873
|
+
const result = parseFrontmatter('title: Plain\ninline: {"a":1}');
|
|
874
|
+
expect(result).toEqual({ title: 'Plain', inline: '{"a":1}' });
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
describe('multi-line frontmatter round-trips', () => {
|
|
879
|
+
it('round-trips a multi-line string value through stringify → parse', () => {
|
|
880
|
+
const value = JSON.stringify({ a: 1, b: [2, 3] }, null, 2);
|
|
881
|
+
const doc = createDocument();
|
|
882
|
+
doc.frontmatter = { payload: value, title: 'Doc' };
|
|
883
|
+
const md = stringifyMarkdown(doc);
|
|
884
|
+
expect(md).toContain('payload: |-');
|
|
885
|
+
const reparsed = parseMarkdown(md);
|
|
886
|
+
expect(reparsed.frontmatter!.payload).toBe(value);
|
|
887
|
+
expect(reparsed.frontmatter!.title).toBe('Doc');
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('setFrontmatterValues emits a block scalar for a multi-line value', () => {
|
|
891
|
+
const value = 'one\ntwo\nthree';
|
|
892
|
+
const out = setFrontmatterValues('# Body\n', { notes: value });
|
|
893
|
+
expect(out).toContain('notes: |-');
|
|
894
|
+
expect(out).toContain(' one');
|
|
895
|
+
const doc = parseMarkdown(out);
|
|
896
|
+
expect(doc.frontmatter!.notes).toBe(value);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('keeps single-line frontmatter output unchanged', () => {
|
|
900
|
+
const doc = createDocument();
|
|
901
|
+
doc.frontmatter = { 'document-render-as': 'portrait' };
|
|
902
|
+
const md = stringifyMarkdown(doc);
|
|
903
|
+
expect(md).toContain('document-render-as: portrait');
|
|
904
|
+
expect(md).not.toContain('|-');
|
|
905
|
+
});
|
|
841
906
|
});
|
|
842
907
|
|
|
843
908
|
describe('frontmatter in parseMarkdown', () => {
|
|
@@ -870,3 +935,25 @@ describe('frontmatter in parseMarkdown', () => {
|
|
|
870
935
|
expect(doc.frontmatter).toBeUndefined();
|
|
871
936
|
});
|
|
872
937
|
});
|
|
938
|
+
|
|
939
|
+
describe('plainTextFromInlineHtml', () => {
|
|
940
|
+
it('strips inline tags, keeping text', () => {
|
|
941
|
+
expect(plainTextFromInlineHtml('<strong>bold</strong> and <em>italic</em>')).toBe(
|
|
942
|
+
'bold and italic',
|
|
943
|
+
);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('decodes entities', () => {
|
|
947
|
+
expect(plainTextFromInlineHtml('a & b <c>')).toBe('a & b <c>');
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('turns <br> and block tags into line breaks', () => {
|
|
951
|
+
expect(plainTextFromInlineHtml('one<br>two')).toBe('one\ntwo');
|
|
952
|
+
expect(plainTextFromInlineHtml('<p>one</p><p>two</p>')).toBe('one\ntwo');
|
|
953
|
+
expect(plainTextFromInlineHtml('<h2>Title</h2><p>body</p>')).toBe('Title\nbody');
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it('returns empty string for empty input', () => {
|
|
957
|
+
expect(plainTextFromInlineHtml('')).toBe('');
|
|
958
|
+
});
|
|
959
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseHtmlToNodes, sanitizeHtmlNodes, sanitizeUrl } from '../markdown/index';
|
|
3
|
+
import type { HtmlElement, HtmlNode } from '../markdown/index';
|
|
4
|
+
|
|
5
|
+
function elements(nodes: HtmlNode[]): HtmlElement[] {
|
|
6
|
+
return nodes.filter((node): node is HtmlElement => node.type === 'htmlElement');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('markdown HTML sanitization', () => {
|
|
10
|
+
it('allows safe link URLs and rejects executable schemes', () => {
|
|
11
|
+
expect(sanitizeUrl('https://example.com', 'link')).toBe('https://example.com');
|
|
12
|
+
expect(sanitizeUrl('/docs/page.html#intro', 'link')).toBe('/docs/page.html#intro');
|
|
13
|
+
expect(sanitizeUrl('mailto:me@example.com', 'link')).toBe('mailto:me@example.com');
|
|
14
|
+
expect(sanitizeUrl('tel:+15551234567', 'link')).toBe('tel:+15551234567');
|
|
15
|
+
expect(sanitizeUrl('javascript:alert(1)', 'link')).toBeNull();
|
|
16
|
+
expect(sanitizeUrl('java\nscript:alert(1)', 'link')).toBeNull();
|
|
17
|
+
expect(sanitizeUrl('data:text/html,<script>alert(1)</script>', 'link')).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('extraLinkSchemes allows host-app schemes but never executable ones', () => {
|
|
21
|
+
expect(sanitizeUrl('gezel-nav:src%2Fa.ts', 'link')).toBeNull();
|
|
22
|
+
expect(sanitizeUrl('gezel-nav:src%2Fa.ts', 'link', { extraLinkSchemes: ['gezel-nav'] })).toBe(
|
|
23
|
+
'gezel-nav:src%2Fa.ts',
|
|
24
|
+
);
|
|
25
|
+
expect(
|
|
26
|
+
sanitizeUrl('javascript:alert(1)', 'link', { extraLinkSchemes: ['javascript'] }),
|
|
27
|
+
).toBeNull();
|
|
28
|
+
expect(sanitizeUrl('data:text/html,x', 'link', { extraLinkSchemes: ['data'] })).toBeNull();
|
|
29
|
+
// media URLs ignore the option entirely
|
|
30
|
+
expect(
|
|
31
|
+
sanitizeUrl('gezel-nav:src%2Fa.ts', 'media', { extraLinkSchemes: ['gezel-nav'] }),
|
|
32
|
+
).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('allows Squisq media URLs without allowing SVG or HTML data payloads', () => {
|
|
36
|
+
expect(sanitizeUrl('blob:http://localhost/abc', 'media')).toBe('blob:http://localhost/abc');
|
|
37
|
+
expect(sanitizeUrl('data:image/png;base64,AAA', 'media')).toBe('data:image/png;base64,AAA');
|
|
38
|
+
expect(sanitizeUrl('data:audio/webm;base64,AAA', 'media')).toBe('data:audio/webm;base64,AAA');
|
|
39
|
+
expect(sanitizeUrl('data:image/svg+xml;base64,AAA', 'media')).toBeNull();
|
|
40
|
+
expect(sanitizeUrl('data:text/html,<script>alert(1)</script>', 'media')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('removes dangerous tags and event handler attributes', () => {
|
|
44
|
+
const safe = sanitizeHtmlNodes(
|
|
45
|
+
parseHtmlToNodes('<div onclick="alert(1)">ok<script>alert(1)</script></div>'),
|
|
46
|
+
);
|
|
47
|
+
const [div] = elements(safe);
|
|
48
|
+
|
|
49
|
+
expect(div.tagName).toBe('div');
|
|
50
|
+
expect(div.attributes.onclick).toBeUndefined();
|
|
51
|
+
expect(div.children).toEqual([{ type: 'htmlText', value: 'ok' }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sanitizes link and image attributes inside raw HTML', () => {
|
|
55
|
+
const safe = sanitizeHtmlNodes(
|
|
56
|
+
parseHtmlToNodes(
|
|
57
|
+
'<a href="javascript:alert(1)" target="_blank">bad</a><img src="x.jpg" onerror="alert(1)" width="300">',
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
const [link, image] = elements(safe);
|
|
61
|
+
|
|
62
|
+
expect(link.tagName).toBe('a');
|
|
63
|
+
expect(link.attributes.href).toBeUndefined();
|
|
64
|
+
expect(link.attributes.target).toBe('_blank');
|
|
65
|
+
expect(link.attributes.rel).toBe('noopener noreferrer');
|
|
66
|
+
expect(image.tagName).toBe('img');
|
|
67
|
+
expect(image.attributes.src).toBe('x.jpg');
|
|
68
|
+
expect(image.attributes.onerror).toBeUndefined();
|
|
69
|
+
expect(image.attributes.width).toBe('300');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('unwraps unknown harmless containers while keeping their safe children', () => {
|
|
73
|
+
const safe = sanitizeHtmlNodes(parseHtmlToNodes('<custom><strong>kept</strong></custom>'));
|
|
74
|
+
const [strong] = elements(safe);
|
|
75
|
+
|
|
76
|
+
expect(strong.tagName).toBe('strong');
|
|
77
|
+
expect(strong.children).toEqual([{ type: 'htmlText', value: 'kept' }]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -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;
|
|
@@ -243,6 +250,31 @@ describe('docToMarkdown (round-trip)', () => {
|
|
|
243
250
|
expect(stripPositions(md2)).toEqual(stripPositions(md));
|
|
244
251
|
});
|
|
245
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
|
+
|
|
246
278
|
it('round-trips preamble content', () => {
|
|
247
279
|
const input = 'Preamble text\n\n# Heading\n\nBody\n';
|
|
248
280
|
const md = parseMarkdown(input);
|
|
@@ -362,6 +394,61 @@ describe('template annotation in markdownToDoc', () => {
|
|
|
362
394
|
expect(output).toContain('{[factCard style=minimal]}');
|
|
363
395
|
});
|
|
364
396
|
|
|
397
|
+
it('docToMarkdown injects transition attributes from block metadata', () => {
|
|
398
|
+
const md = parseMarkdown('## Section\n\nBody');
|
|
399
|
+
const doc = markdownToDoc(md);
|
|
400
|
+
doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
|
|
401
|
+
|
|
402
|
+
const output = stringifyMarkdown(docToMarkdown(doc));
|
|
403
|
+
|
|
404
|
+
expect(output).toContain('{transition=wipe transitionDuration=0.8 transitionDirection=left}');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('a programmatic transition survives the string round trip', () => {
|
|
408
|
+
const doc = markdownToDoc(parseMarkdown('## Section\n\nBody'));
|
|
409
|
+
doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
|
|
410
|
+
|
|
411
|
+
const reparsed = markdownToDoc(parseMarkdown(stringifyMarkdown(docToMarkdown(doc))));
|
|
412
|
+
|
|
413
|
+
expect(reparsed.blocks[0].transition).toEqual({
|
|
414
|
+
type: 'wipe',
|
|
415
|
+
duration: 0.8,
|
|
416
|
+
direction: 'left',
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('a programmatic transition survives the in-memory round trip (no stringify)', () => {
|
|
421
|
+
// markdownToDoc reads heading.attributes.blockMeta directly — it never
|
|
422
|
+
// re-coerces params — so docToMarkdown must keep blockMeta in sync with
|
|
423
|
+
// the params it writes for stringifyMarkdown.
|
|
424
|
+
const doc = markdownToDoc(parseMarkdown('## Section\n\nBody'));
|
|
425
|
+
doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
|
|
426
|
+
|
|
427
|
+
const reconverted = markdownToDoc(docToMarkdown(doc));
|
|
428
|
+
|
|
429
|
+
expect(reconverted.blocks[0].transition).toEqual({
|
|
430
|
+
type: 'wipe',
|
|
431
|
+
duration: 0.8,
|
|
432
|
+
direction: 'left',
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('an updated transition replaces a previously authored one in both round trips', () => {
|
|
437
|
+
// The heading already carries a parsed transition (params + blockMeta);
|
|
438
|
+
// a programmatic change must win over the stale blockMeta.
|
|
439
|
+
const doc = markdownToDoc(parseMarkdown('## Section {transition=fade}\n\nBody'));
|
|
440
|
+
expect(doc.blocks[0].transition).toEqual({ type: 'fade' });
|
|
441
|
+
doc.blocks[0].transition = { type: 'wipe', direction: 'left' };
|
|
442
|
+
|
|
443
|
+
const inMemory = markdownToDoc(docToMarkdown(doc));
|
|
444
|
+
expect(inMemory.blocks[0].transition).toEqual({ type: 'wipe', direction: 'left' });
|
|
445
|
+
|
|
446
|
+
const output = stringifyMarkdown(docToMarkdown(doc));
|
|
447
|
+
expect(output).toContain('transition=wipe');
|
|
448
|
+
const reparsed = markdownToDoc(parseMarkdown(output));
|
|
449
|
+
expect(reparsed.blocks[0].transition).toEqual({ type: 'wipe', direction: 'left' });
|
|
450
|
+
});
|
|
451
|
+
|
|
365
452
|
it('nested headings preserve their own annotations', () => {
|
|
366
453
|
const input = '# Chapter {[title]}\n\nIntro\n\n## Section {[chart]}\n\nData';
|
|
367
454
|
const md = parseMarkdown(input);
|
|
@@ -448,12 +535,16 @@ describe('caption generation', () => {
|
|
|
448
535
|
expect(doc.captions).toBeUndefined();
|
|
449
536
|
});
|
|
450
537
|
|
|
451
|
-
it('generates captions version
|
|
538
|
+
it('generates captions version; timestamp only when supplied (deterministic default)', () => {
|
|
452
539
|
const md = parseMarkdown('# Title\n\nHello world and more text.');
|
|
453
540
|
const doc = markdownToDoc(md);
|
|
454
541
|
|
|
455
542
|
expect(doc.captions!.version).toBe(1);
|
|
456
|
-
|
|
543
|
+
// No clock reads during conversion — generatedAt is opt-in.
|
|
544
|
+
expect(doc.captions!.generatedAt).toBeUndefined();
|
|
545
|
+
|
|
546
|
+
const stamped = markdownToDoc(md, { captionsGeneratedAt: '2026-01-01T00:00:00.000Z' });
|
|
547
|
+
expect(stamped.captions!.generatedAt).toBe('2026-01-01T00:00:00.000Z');
|
|
457
548
|
});
|
|
458
549
|
|
|
459
550
|
it('caption phrases have sequential non-overlapping times', () => {
|
|
@@ -563,3 +654,301 @@ describe('auto cover block generation', () => {
|
|
|
563
654
|
expect(doc.startBlock!.subtitle).toBeUndefined();
|
|
564
655
|
});
|
|
565
656
|
});
|
|
657
|
+
|
|
658
|
+
describe('markdownToDoc with Pandoc attributes', () => {
|
|
659
|
+
it('overrides block id from {#id}', () => {
|
|
660
|
+
const block = firstHeadingBlock('## Section title {#intro}');
|
|
661
|
+
expect(block.id).toBe('intro');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('applies x / y to typed Block fields', () => {
|
|
665
|
+
// `diagramNode` is a legacy alias for the canonical `diagram` template;
|
|
666
|
+
// the alias resolves at template-resolution time.
|
|
667
|
+
const block = firstHeadingBlock('## Step 1 {#step1 x=400 y=200} {[diagramNode]}');
|
|
668
|
+
expect(block.id).toBe('step1');
|
|
669
|
+
expect(block.x).toBe(400);
|
|
670
|
+
expect(block.y).toBe(200);
|
|
671
|
+
expect(block.template).toBe('diagram');
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('applies multiple connectsTo entries with mixed types', () => {
|
|
675
|
+
const block = firstHeadingBlock(
|
|
676
|
+
'## Architecture {#arch x=600 y=300 connectsTo=foo:veryImportant,bar:requires,baz:requires} {[diagramNode]}',
|
|
677
|
+
);
|
|
678
|
+
expect(block.connectsTo).toEqual([
|
|
679
|
+
{ target: 'foo', type: 'veryImportant' },
|
|
680
|
+
{ target: 'bar', type: 'requires' },
|
|
681
|
+
{ target: 'baz', type: 'requires' },
|
|
682
|
+
]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('parses connections without types', () => {
|
|
686
|
+
const block = firstHeadingBlock('## Summary {#summary connectsTo=intro,middle,end}');
|
|
687
|
+
expect(block.connectsTo).toEqual([
|
|
688
|
+
{ target: 'intro' },
|
|
689
|
+
{ target: 'middle' },
|
|
690
|
+
{ target: 'end' },
|
|
691
|
+
]);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('parses mix of typed and untyped connections in one list', () => {
|
|
695
|
+
const block = firstHeadingBlock(
|
|
696
|
+
'## Decision {#decision connectsTo=alt1,alt2:rejected,alt3:chosen}',
|
|
697
|
+
);
|
|
698
|
+
expect(block.connectsTo).toEqual([
|
|
699
|
+
{ target: 'alt1' },
|
|
700
|
+
{ target: 'alt2', type: 'rejected' },
|
|
701
|
+
{ target: 'alt3', type: 'chosen' },
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('overrides startTime / duration with attribute values (seconds)', () => {
|
|
706
|
+
const block = firstHeadingBlock('## Closing {#closing startTime=02:30 duration=00:45}');
|
|
707
|
+
expect(block.startTime).toBe(150);
|
|
708
|
+
expect(block.duration).toBe(45);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('sets transition metadata from Pandoc attributes', () => {
|
|
712
|
+
const block = firstHeadingBlock(
|
|
713
|
+
'## Closing {#closing transition=checkerboard transitionDuration=1.2 transitionDirection=vertical}',
|
|
714
|
+
);
|
|
715
|
+
expect(block.transition).toEqual({
|
|
716
|
+
type: 'checkerboard',
|
|
717
|
+
duration: 1.2,
|
|
718
|
+
direction: 'vertical',
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('puts unknown keys into block.metadata', () => {
|
|
723
|
+
const block = firstHeadingBlock('## X {#x priority=high status=draft}');
|
|
724
|
+
expect(block.metadata).toEqual({ priority: 'high', status: 'draft' });
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('routes template params separately from block-level metadata', () => {
|
|
728
|
+
const block = firstHeadingBlock(
|
|
729
|
+
'## X {#big x=400 priority=high} {[diagramNode colorScheme=blue]}',
|
|
730
|
+
);
|
|
731
|
+
expect(block.templateOverrides).toEqual({ colorScheme: 'blue' });
|
|
732
|
+
expect(block.metadata).toEqual({ priority: 'high' });
|
|
733
|
+
expect(block.x).toBe(400);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('stores Pandoc classes on block.classes', () => {
|
|
737
|
+
const block = firstHeadingBlock('## X {#x .alpha .beta}');
|
|
738
|
+
expect(block.classes).toEqual(['alpha', 'beta']);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('backward compat: plain {[…]}-only heading is unchanged', () => {
|
|
742
|
+
const block = firstHeadingBlock('## X {[chart colorScheme=blue]}');
|
|
743
|
+
expect(block.template).toBe('chart');
|
|
744
|
+
expect(block.templateOverrides).toEqual({ colorScheme: 'blue' });
|
|
745
|
+
expect(block.x).toBeUndefined();
|
|
746
|
+
expect(block.metadata).toBeUndefined();
|
|
747
|
+
expect(block.classes).toBeUndefined();
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
describe('markdownToDoc block-meta in the squiggly {[…]} form', () => {
|
|
752
|
+
it('reads duration from a pure key-value {[duration=N]} annotation', () => {
|
|
753
|
+
const block = firstHeadingBlock('## Closing {[duration=8]}\n\nSome body text here.\n');
|
|
754
|
+
expect(block.duration).toBe(8);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('does not let reading-time overwrite a squiggly-pinned duration', () => {
|
|
758
|
+
// Body long enough that the reading-time estimate would exceed 8s if it ran.
|
|
759
|
+
const body = Array(80).fill('word').join(' ');
|
|
760
|
+
const block = firstHeadingBlock(`## Closing {[duration=8]}\n\n${body}\n`);
|
|
761
|
+
expect(block.duration).toBe(8);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('folds duration alongside a template name', () => {
|
|
765
|
+
const block = firstHeadingBlock('## Hero {[sectionHeader duration=6]}');
|
|
766
|
+
expect(block.template).toBe('sectionHeader');
|
|
767
|
+
expect(block.duration).toBe(6);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('folds transition alongside a template name', () => {
|
|
771
|
+
const block = firstHeadingBlock(
|
|
772
|
+
'## Hero {[sectionHeader transition=fly-through transitionDuration=750ms]}',
|
|
773
|
+
);
|
|
774
|
+
expect(block.template).toBe('sectionHeader');
|
|
775
|
+
expect(block.transition).toEqual({
|
|
776
|
+
type: 'flyThrough',
|
|
777
|
+
duration: 0.75,
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('accepts startTime in the squiggly form too', () => {
|
|
782
|
+
const block = firstHeadingBlock('## Closing {[startTime=02:30 duration=45]}');
|
|
783
|
+
expect(block.startTime).toBe(150);
|
|
784
|
+
expect(block.duration).toBe(45);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('Pandoc attrs win when a key appears in both forms', () => {
|
|
788
|
+
const block = firstHeadingBlock('## X {duration=3} {[duration=9]}');
|
|
789
|
+
expect(block.duration).toBe(3);
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
describe('auto template picking (autoTemplates, default on)', () => {
|
|
794
|
+
const toDocFromMd = (md: string, options?: Parameters<typeof markdownToDoc>[1]) =>
|
|
795
|
+
markdownToDoc(parseMarkdown(md), options);
|
|
796
|
+
|
|
797
|
+
it('picks quote for a blockquote-bearing section and derives the quote text', () => {
|
|
798
|
+
const doc = toDocFromMd('# Wisdom\n\n> The best template feels obvious.\n');
|
|
799
|
+
const block = doc.blocks[0];
|
|
800
|
+
expect(block.template).toBe('quote');
|
|
801
|
+
expect(block.autoTemplate).toBe(true);
|
|
802
|
+
expect(block.templateData?.quote).toBe('The best template feels obvious.');
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('picks dataTable for a table-bearing section with derived headers/rows', () => {
|
|
806
|
+
const doc = toDocFromMd('# Results\n\n| A | B |\n| - | - |\n| 1 | 2 |\n');
|
|
807
|
+
const block = doc.blocks[0];
|
|
808
|
+
expect(block.template).toBe('dataTable');
|
|
809
|
+
expect(block.templateData?.headers).toEqual(['A', 'B']);
|
|
810
|
+
expect(block.templateData?.rows).toEqual([['1', '2']]);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('picks list for list-bearing sections and photoGrid for multi-image sections', () => {
|
|
814
|
+
const doc = toDocFromMd('# Steps\n\n- one\n- two\n\n# Gallery\n\n\n\n\n');
|
|
815
|
+
expect(doc.blocks[0].template).toBe('list');
|
|
816
|
+
expect(doc.blocks[0].templateData?.items).toEqual(['one', 'two']);
|
|
817
|
+
expect(doc.blocks[1].template).toBe('photoGrid');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('alternates left/right feature for consecutive single-image sections', () => {
|
|
821
|
+
const doc = toDocFromMd('# One\n\n\n\n# Two\n\n\n');
|
|
822
|
+
expect(doc.blocks[0].template).toBe('leftFeature');
|
|
823
|
+
expect(doc.blocks[1].template).toBe('rightFeature');
|
|
824
|
+
expect(doc.blocks[0].templateData?.imageSrc).toBe('a.jpg');
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('leaves plain prose sections on the structural default', () => {
|
|
828
|
+
const doc = toDocFromMd('# About\n\nJust a paragraph of prose.\n');
|
|
829
|
+
expect(doc.blocks[0].template).toBe('sectionHeader');
|
|
830
|
+
expect(doc.blocks[0].autoTemplate).toBeUndefined();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('never overrides an explicit annotation', () => {
|
|
834
|
+
const doc = toDocFromMd('# Wisdom {[factCard fact="F"]}\n\n> quoted\n');
|
|
835
|
+
expect(doc.blocks[0].template).toBe('factCard');
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('is disabled by the option and by frontmatter', () => {
|
|
839
|
+
const md = '# Wisdom\n\n> quoted\n';
|
|
840
|
+
const viaOption = toDocFromMd(md, { autoTemplates: false });
|
|
841
|
+
expect(viaOption.blocks[0].template).toBe('sectionHeader');
|
|
842
|
+
|
|
843
|
+
const viaFrontmatter = markdownToDoc(
|
|
844
|
+
parseMarkdown('---\nsquisq-auto-templates: false\n---\n\n' + md),
|
|
845
|
+
);
|
|
846
|
+
expect(viaFrontmatter.blocks[0].template).toBe('sectionHeader');
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('keeps the round-trip lossless (no materialized annotations)', () => {
|
|
850
|
+
const md = '# Wisdom\n\n> The best template feels obvious.\n';
|
|
851
|
+
const doc = toDocFromMd(md);
|
|
852
|
+
expect(doc.blocks[0].template).toBe('quote');
|
|
853
|
+
const roundTripped = stringifyMarkdown(docToMarkdown(doc));
|
|
854
|
+
expect(roundTripped).not.toContain('{[quote');
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
describe('standalone annotation blocks (S2)', () => {
|
|
859
|
+
const toDoc = (md: string, options?: Parameters<typeof markdownToDoc>[1]) =>
|
|
860
|
+
markdownToDoc(parseMarkdown(md), { articleId: 't', generateCoverBlock: false, ...options });
|
|
861
|
+
|
|
862
|
+
it('turns a standalone {[template]} paragraph into a heading-less block', () => {
|
|
863
|
+
const doc = toDoc('# Intro\n\nlead para\n\n{[quote]}\n\nquoted body\n');
|
|
864
|
+
// Containing block keeps only the leading content.
|
|
865
|
+
const intro = doc.blocks[0];
|
|
866
|
+
expect(intro.title).toBe('Intro');
|
|
867
|
+
expect(intro.contents?.map((n) => n.type)).toEqual(['paragraph']);
|
|
868
|
+
// The produced block is a sibling right after the containing block.
|
|
869
|
+
const q = doc.blocks[1];
|
|
870
|
+
expect(q.template).toBe('quote');
|
|
871
|
+
expect(q.standaloneAnnotation).toBe(true);
|
|
872
|
+
expect(q.sourceHeading).toBeUndefined();
|
|
873
|
+
expect(q.sourceAnnotation).toEqual({ template: 'quote' });
|
|
874
|
+
expect(q.contents?.map((n) => n.type)).toEqual(['paragraph']);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('attaches trailing contents up to the next annotation; params → overrides + title', () => {
|
|
878
|
+
const doc = toDoc(
|
|
879
|
+
'# H\n\n{[statHighlight title="Q1 Revenue" colorScheme=blue]}\n\nbig number\n\n{[quote]}\n\nq\n',
|
|
880
|
+
);
|
|
881
|
+
const stat = doc.blocks[1];
|
|
882
|
+
expect(stat.template).toBe('statHighlight');
|
|
883
|
+
expect(stat.title).toBe('Q1 Revenue');
|
|
884
|
+
expect(stat.templateOverrides).toEqual({ title: 'Q1 Revenue', colorScheme: 'blue' });
|
|
885
|
+
expect(stat.contents?.length).toBe(1); // "big number", ended by the next annotation
|
|
886
|
+
const quote = doc.blocks[2];
|
|
887
|
+
expect(quote.template).toBe('quote');
|
|
888
|
+
expect(quote.contents?.length).toBe(1); // "q"
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('produces adjacent blocks for consecutive annotations (no content between)', () => {
|
|
892
|
+
const doc = toDoc('# H\n\n{[quote]}\n\n{[factCard]}\n\ntail\n');
|
|
893
|
+
expect(doc.blocks[1].template).toBe('quote');
|
|
894
|
+
expect(doc.blocks[1].contents ?? []).toHaveLength(0);
|
|
895
|
+
expect(doc.blocks[2].template).toBe('factCard');
|
|
896
|
+
expect(doc.blocks[2].contents?.length).toBe(1); // "tail"
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('handles an annotation as the last node (no trailing contents)', () => {
|
|
900
|
+
const doc = toDoc('# H\n\nbody\n\n{[quote]}\n');
|
|
901
|
+
expect(doc.blocks[1].template).toBe('quote');
|
|
902
|
+
expect(doc.blocks[1].contents ?? []).toHaveLength(0);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('generates unique slug ids that dedupe against headings', () => {
|
|
906
|
+
const doc = toDoc('# Quote\n\n{[quote title=Quote]}\n\nbody\n');
|
|
907
|
+
expect(doc.blocks[0].id).toBe('quote');
|
|
908
|
+
expect(doc.blocks[1].id).toBe('quote-2');
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('lifts an annotation out of the preamble (before the first heading)', () => {
|
|
912
|
+
const doc = toDoc('intro\n\n{[quote]}\n\nquoted\n\n# Heading\n\nrest\n');
|
|
913
|
+
// preamble keeps "intro", quote block next, then the heading block.
|
|
914
|
+
expect(doc.blocks[0].sourceHeading).toBeUndefined();
|
|
915
|
+
expect(doc.blocks[0].contents?.length).toBe(1);
|
|
916
|
+
expect(doc.blocks[1].template).toBe('quote');
|
|
917
|
+
expect(doc.blocks[2].title).toBe('Heading');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('drops a preamble that was entirely a standalone annotation', () => {
|
|
921
|
+
const doc = toDoc('{[quote]}\n\nquoted\n\n# Heading\n');
|
|
922
|
+
expect(doc.blocks[0].template).toBe('quote');
|
|
923
|
+
expect(doc.blocks[0].standaloneAnnotation).toBe(true);
|
|
924
|
+
expect(doc.blocks[1].title).toBe('Heading');
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('honors a pinned duration on a standalone annotation', () => {
|
|
928
|
+
const doc = toDoc('# H\n\n{[quote duration=9]}\n\nbody\n');
|
|
929
|
+
expect(doc.blocks[1].duration).toBe(9);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('does not treat annotations nested inside a list as blocks', () => {
|
|
933
|
+
const doc = toDoc('# H\n\n- {[quote]}\n');
|
|
934
|
+
// Only the heading block — the list annotation stays in contents.
|
|
935
|
+
expect(doc.blocks).toHaveLength(1);
|
|
936
|
+
expect(doc.blocks[0].contents?.[0].type).toBe('list');
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it('round-trips a standalone annotation block byte-for-byte', () => {
|
|
940
|
+
const md = '# Intro\n\nlead\n\n{[quote]}\n\nquoted body\n';
|
|
941
|
+
const doc = toDoc(md);
|
|
942
|
+
const out = stringifyMarkdown(docToMarkdown(doc));
|
|
943
|
+
expect(out).toBe(md);
|
|
944
|
+
// And re-parsing is stable.
|
|
945
|
+
const doc2 = toDoc(out);
|
|
946
|
+
expect(stringifyMarkdown(docToMarkdown(doc2))).toBe(md);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('round-trips params (quoting) byte-for-byte', () => {
|
|
950
|
+
const md = '# H\n\n{[statHighlight title="Q1 Revenue" colorScheme=blue]}\n\nbody\n';
|
|
951
|
+
const out = stringifyMarkdown(docToMarkdown(toDoc(md)));
|
|
952
|
+
expect(out).toBe(md);
|
|
953
|
+
});
|
|
954
|
+
});
|