@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
|
@@ -108,7 +108,7 @@ describe('getLayers', () => {
|
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
it('
|
|
111
|
+
it('renders a visible fallback card for unknown templates (never blank)', () => {
|
|
112
112
|
const block = {
|
|
113
113
|
template: 'totally_nonexistent',
|
|
114
114
|
id: 'unknown-1',
|
|
@@ -117,7 +117,11 @@ describe('getLayers', () => {
|
|
|
117
117
|
} as unknown as TemplateBlock;
|
|
118
118
|
const layers = getLayers(block, defaultContext);
|
|
119
119
|
|
|
120
|
-
expect(layers).
|
|
120
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
121
|
+
const noticeText = layers
|
|
122
|
+
.map((l) => (l.type === 'text' ? ((l.content as { text?: string }).text ?? '') : ''))
|
|
123
|
+
.join('\n');
|
|
124
|
+
expect(noticeText).toContain('Unknown template "totally_nonexistent"');
|
|
121
125
|
});
|
|
122
126
|
|
|
123
127
|
it('returns empty array for block with no template and no layers', () => {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { parseMarkdown } from '../markdown/parse.js';
|
|
3
|
+
import { markdownToDoc } from '../doc/markdownToDoc.js';
|
|
4
|
+
import { getLayers } from '../doc/getLayers.js';
|
|
5
|
+
import { templateRegistry } from '../doc/templates/index.js';
|
|
6
|
+
import type { Block, Layer } from '../schemas/Doc.js';
|
|
7
|
+
|
|
8
|
+
function textOf(layers: Layer[]): string {
|
|
9
|
+
return layers
|
|
10
|
+
.map((l) => (l.type === 'text' ? ((l.content as { text?: string }).text ?? '') : ''))
|
|
11
|
+
.join('\n');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function firstBlock(md: string): Block {
|
|
15
|
+
return markdownToDoc(parseMarkdown(md)).blocks[0];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('getLayers — graceful degradation', () => {
|
|
19
|
+
it('renders an unknown template as a plain card with title, body, and notice', () => {
|
|
20
|
+
const block = firstBlock('## My Section {[photGrid]}\n\nSome body prose here.');
|
|
21
|
+
const layers = getLayers(block);
|
|
22
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
23
|
+
const text = textOf(layers);
|
|
24
|
+
expect(text).toContain('My Section');
|
|
25
|
+
expect(text).toContain('Some body prose here.');
|
|
26
|
+
expect(text).toContain('Unknown template "photGrid"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders a fallback card when a template function throws', () => {
|
|
30
|
+
(templateRegistry as Record<string, unknown>)['__boom'] = () => {
|
|
31
|
+
throw new Error('kaboom');
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const block: Block = {
|
|
35
|
+
id: 'b1',
|
|
36
|
+
startTime: 0,
|
|
37
|
+
duration: 5,
|
|
38
|
+
audioSegment: 0,
|
|
39
|
+
template: '__boom',
|
|
40
|
+
title: 'Exploding block',
|
|
41
|
+
};
|
|
42
|
+
const layers = getLayers(block);
|
|
43
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
44
|
+
expect(textOf(layers)).toContain('Template "__boom" failed');
|
|
45
|
+
expect(textOf(layers)).toContain('Exploding block');
|
|
46
|
+
} finally {
|
|
47
|
+
delete (templateRegistry as Record<string, unknown>)['__boom'];
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('blocks with no template at all still return empty layers', () => {
|
|
52
|
+
const block: Block = { id: 'raw', startTime: 0, duration: 5, audioSegment: 0 };
|
|
53
|
+
expect(getLayers(block)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getLayers — structured data feeds templates', () => {
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
delete (templateRegistry as Record<string, unknown>)['__capture'];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('merges templateData and templateOverrides into the template input', () => {
|
|
63
|
+
let received: Record<string, unknown> | null = null;
|
|
64
|
+
(templateRegistry as Record<string, unknown>)['__capture'] = (
|
|
65
|
+
input: Record<string, unknown>,
|
|
66
|
+
) => {
|
|
67
|
+
received = input;
|
|
68
|
+
return [];
|
|
69
|
+
};
|
|
70
|
+
const block: Block = {
|
|
71
|
+
id: 'b1',
|
|
72
|
+
startTime: 0,
|
|
73
|
+
duration: 5,
|
|
74
|
+
audioSegment: 0,
|
|
75
|
+
template: '__capture',
|
|
76
|
+
templateData: { rows: [['a', 'b']], zoom: 12 },
|
|
77
|
+
templateOverrides: { zoom: '14' },
|
|
78
|
+
};
|
|
79
|
+
getLayers(block);
|
|
80
|
+
expect(received).not.toBeNull();
|
|
81
|
+
expect(received!['rows']).toEqual([['a', 'b']]);
|
|
82
|
+
// String overrides from {[…]} params win last.
|
|
83
|
+
expect(received!['zoom']).toBe('14');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders a dataTable from a GFM table in the markdown body', () => {
|
|
87
|
+
const md = [
|
|
88
|
+
'## People {[dataTable]}',
|
|
89
|
+
'',
|
|
90
|
+
'| Name | Age |',
|
|
91
|
+
'| --- | --- |',
|
|
92
|
+
'| Alice | 30 |',
|
|
93
|
+
].join('\n');
|
|
94
|
+
const block = firstBlock(md);
|
|
95
|
+
const layers = getLayers(block);
|
|
96
|
+
expect(layers.length).toBeGreaterThan(0);
|
|
97
|
+
const tableLayer = layers.find((l) => l.type === 'table');
|
|
98
|
+
expect(tableLayer).toBeDefined();
|
|
99
|
+
const content = tableLayer!.content as { headers?: string[]; rows?: string[][] };
|
|
100
|
+
expect(content.headers).toEqual(['Name', 'Age']);
|
|
101
|
+
expect(content.rows).toEqual([['Alice', '30']]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildSvgString } from '../imageEdit/export';
|
|
3
|
+
import { createEmptyImageEditDoc, addLayer } from '../imageEdit/state';
|
|
4
|
+
import { MemoryContentContainer } from '../storage/ContentContainer';
|
|
5
|
+
import type { ImageEditLayer } from '../schemas/ImageEditDoc';
|
|
6
|
+
|
|
7
|
+
const container = new MemoryContentContainer();
|
|
8
|
+
|
|
9
|
+
describe('buildSvgString — path (drawing shape) layers', () => {
|
|
10
|
+
it('renders a named shape (shapeKind) as a <path>, deriving d from the box', async () => {
|
|
11
|
+
let doc = createEmptyImageEditDoc(200, 200);
|
|
12
|
+
const diamond: ImageEditLayer = {
|
|
13
|
+
id: 'p1',
|
|
14
|
+
type: 'path',
|
|
15
|
+
position: { x: 10, y: 20, width: 100, height: 80 },
|
|
16
|
+
content: {
|
|
17
|
+
d: 'M 0 0',
|
|
18
|
+
shapeKind: 'diamond',
|
|
19
|
+
fill: '#3399ff',
|
|
20
|
+
stroke: '#1a4d80',
|
|
21
|
+
strokeWidth: 2,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
doc = addLayer(doc, diamond);
|
|
25
|
+
|
|
26
|
+
const svg = await buildSvgString(doc, container);
|
|
27
|
+
expect(svg).toContain('<path');
|
|
28
|
+
expect(svg).toContain('fill="#3399ff"');
|
|
29
|
+
expect(svg).toContain('stroke="#1a4d80"');
|
|
30
|
+
// A diamond inscribed in [10,20,100,80] starts at the top-center (60,20).
|
|
31
|
+
expect(svg).toContain('d="M 60 20');
|
|
32
|
+
// The seed `d="M 0 0"` must NOT survive — geometry is re-derived.
|
|
33
|
+
expect(svg).not.toContain('d="M 0 0"');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('renders an arrow path with an end marker def', async () => {
|
|
37
|
+
let doc = createEmptyImageEditDoc(200, 200);
|
|
38
|
+
const arrow: ImageEditLayer = {
|
|
39
|
+
id: 'a1',
|
|
40
|
+
type: 'path',
|
|
41
|
+
position: { x: 0, y: 0, width: 100, height: 100 },
|
|
42
|
+
content: {
|
|
43
|
+
d: 'M 0 0 L 100 100',
|
|
44
|
+
stroke: '#000000',
|
|
45
|
+
strokeWidth: 2,
|
|
46
|
+
fill: 'none',
|
|
47
|
+
endMarker: 'arrow',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
doc = addLayer(doc, arrow);
|
|
51
|
+
|
|
52
|
+
const svg = await buildSvgString(doc, container);
|
|
53
|
+
expect(svg).toContain('<marker id="marker-end-a1"');
|
|
54
|
+
expect(svg).toContain('marker-end="url(#marker-end-a1)"');
|
|
55
|
+
expect(svg).toContain('fill="none"');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('still renders native rect/circle/line shape layers', async () => {
|
|
59
|
+
let doc = createEmptyImageEditDoc(100, 100);
|
|
60
|
+
doc = addLayer(doc, {
|
|
61
|
+
id: 's1',
|
|
62
|
+
type: 'shape',
|
|
63
|
+
position: { x: 5, y: 5, width: 40, height: 30 },
|
|
64
|
+
content: { shape: 'rect', fill: '#fff', borderRadius: 4 },
|
|
65
|
+
});
|
|
66
|
+
const svg = await buildSvgString(doc, container);
|
|
67
|
+
expect(svg).toContain('<rect x="5" y="5" width="40" height="30"');
|
|
68
|
+
expect(svg).toContain('rx="4"');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -46,6 +46,78 @@ describe('imageEdit/persistence', () => {
|
|
|
46
46
|
);
|
|
47
47
|
await expect(readImageEditDoc(sidecar)).rejects.toThrow(/unsupported schema version/);
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
async function writeRaw(sidecar: ReturnType<typeof scopeContainer>, payload: unknown) {
|
|
51
|
+
await sidecar.writeFile('state.json', new TextEncoder().encode(JSON.stringify(payload)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('rejects layer that is not an object', async () => {
|
|
55
|
+
const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
|
|
56
|
+
await writeRaw(sidecar, { version: 1, canvas: { width: 1, height: 1 }, layers: [null] });
|
|
57
|
+
await expect(readImageEditDoc(sidecar)).rejects.toThrow(/layers\[0\] must be an object/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects layer with missing id', async () => {
|
|
61
|
+
const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
|
|
62
|
+
await writeRaw(sidecar, {
|
|
63
|
+
version: 1,
|
|
64
|
+
canvas: { width: 1, height: 1 },
|
|
65
|
+
layers: [{ type: 'text', position: { x: 0, y: 0 }, content: { text: 'hi', style: {} } }],
|
|
66
|
+
});
|
|
67
|
+
await expect(readImageEditDoc(sidecar)).rejects.toThrow(/must have a string id/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects layer with invalid type', async () => {
|
|
71
|
+
const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
|
|
72
|
+
await writeRaw(sidecar, {
|
|
73
|
+
version: 1,
|
|
74
|
+
canvas: { width: 1, height: 1 },
|
|
75
|
+
layers: [{ id: 'x', type: 'video', position: { x: 0, y: 0 }, content: {} }],
|
|
76
|
+
});
|
|
77
|
+
await expect(readImageEditDoc(sidecar)).rejects.toThrow(
|
|
78
|
+
/type must be one of image\|text\|shape/,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects image layer with missing content.src', async () => {
|
|
83
|
+
const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
|
|
84
|
+
await writeRaw(sidecar, {
|
|
85
|
+
version: 1,
|
|
86
|
+
canvas: { width: 1, height: 1 },
|
|
87
|
+
layers: [{ id: 'x', type: 'image', position: { x: 0, y: 0 }, content: { alt: 'test' } }],
|
|
88
|
+
});
|
|
89
|
+
await expect(readImageEditDoc(sidecar)).rejects.toThrow(/content\.src must be a string/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects text layer with missing content.text', async () => {
|
|
93
|
+
const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
|
|
94
|
+
await writeRaw(sidecar, {
|
|
95
|
+
version: 1,
|
|
96
|
+
canvas: { width: 1, height: 1 },
|
|
97
|
+
layers: [{ id: 'x', type: 'text', position: { x: 0, y: 0 }, content: { style: {} } }],
|
|
98
|
+
});
|
|
99
|
+
await expect(readImageEditDoc(sidecar)).rejects.toThrow(/content\.text must be a string/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects shape layer with missing content.shape', async () => {
|
|
103
|
+
const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
|
|
104
|
+
await writeRaw(sidecar, {
|
|
105
|
+
version: 1,
|
|
106
|
+
canvas: { width: 1, height: 1 },
|
|
107
|
+
layers: [{ id: 'x', type: 'shape', position: { x: 0, y: 0 }, content: {} }],
|
|
108
|
+
});
|
|
109
|
+
await expect(readImageEditDoc(sidecar)).rejects.toThrow(/content\.shape must be a string/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects layer with missing position', async () => {
|
|
113
|
+
const sidecar = scopeContainer(new MemoryContentContainer(), 'sc');
|
|
114
|
+
await writeRaw(sidecar, {
|
|
115
|
+
version: 1,
|
|
116
|
+
canvas: { width: 1, height: 1 },
|
|
117
|
+
layers: [{ id: 'x', type: 'shape', content: { shape: 'rect' } }],
|
|
118
|
+
});
|
|
119
|
+
await expect(readImageEditDoc(sidecar)).rejects.toThrow(/position must have/);
|
|
120
|
+
});
|
|
49
121
|
});
|
|
50
122
|
|
|
51
123
|
describe('imageEdit/versions', () => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
hasIconMarker,
|
|
4
|
+
iconClass,
|
|
5
|
+
iconMarker,
|
|
6
|
+
splitIconMarkers,
|
|
7
|
+
stripIconMarkers,
|
|
8
|
+
} from '../icons/inlineIconMarker';
|
|
9
|
+
|
|
10
|
+
describe('inline icon markers', () => {
|
|
11
|
+
it('encodes icons with a private-use sentinel', () => {
|
|
12
|
+
expect(iconMarker('solid', 'rocket')).toBe('\uE000solid:rocket\uE000');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('detects and strips encoded markers from plain-text projections', () => {
|
|
16
|
+
const text = `Launch ${iconMarker('solid', 'rocket')} now`;
|
|
17
|
+
|
|
18
|
+
expect(hasIconMarker(text)).toBe(true);
|
|
19
|
+
expect(stripIconMarkers(text)).toBe('Launch now');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('splits marker-bearing text into text and icon runs', () => {
|
|
23
|
+
const text = `A ${iconMarker('regular', 'star')} and ${iconMarker('brands', 'github')}`;
|
|
24
|
+
|
|
25
|
+
expect(splitIconMarkers(text)).toEqual([
|
|
26
|
+
{ type: 'text', text: 'A ' },
|
|
27
|
+
{ type: 'icon', family: 'regular', name: 'star' },
|
|
28
|
+
{ type: 'text', text: ' and ' },
|
|
29
|
+
{ type: 'icon', family: 'brands', name: 'github' },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('builds FontAwesome class names', () => {
|
|
34
|
+
expect(iconClass('solid', 'rocket')).toBe('fa-solid fa-rocket');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { layoutBlock } from '../doc/templates/layoutBlock.js';
|
|
3
|
+
import { createTemplateContext } from '../schemas/BlockTemplates.js';
|
|
4
|
+
import { DEFAULT_THEME } from '../schemas/themeLibrary.js';
|
|
5
|
+
import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
|
|
6
|
+
import { isContainerTemplate } from '../doc/templates/index.js';
|
|
7
|
+
import { parseMarkdown } from '../markdown/parse.js';
|
|
8
|
+
import type { Block, Layer, TextLayer } from '../schemas/Doc.js';
|
|
9
|
+
import type { RawLayersInput } from '../schemas/BlockTemplates.js';
|
|
10
|
+
|
|
11
|
+
function child(
|
|
12
|
+
id: string,
|
|
13
|
+
template: string,
|
|
14
|
+
overrides: Record<string, string> = {},
|
|
15
|
+
bodyMd = '',
|
|
16
|
+
): Block {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
startTime: 0,
|
|
20
|
+
duration: 0,
|
|
21
|
+
audioSegment: 0,
|
|
22
|
+
template,
|
|
23
|
+
templateOverrides: overrides,
|
|
24
|
+
...(bodyMd ? { contents: parseMarkdown(bodyMd).children } : {}),
|
|
25
|
+
} as Block;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ctxWith(children: Block[]) {
|
|
29
|
+
const ctx = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
|
|
30
|
+
ctx.children = children;
|
|
31
|
+
return ctx;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const INPUT: RawLayersInput = { template: 'layout', id: 'lay', duration: 0, audioSegment: 0 };
|
|
35
|
+
|
|
36
|
+
describe('layoutBlock template', () => {
|
|
37
|
+
it('returns a single hint layer for an empty layout', () => {
|
|
38
|
+
const layers = layoutBlock(INPUT, ctxWith([]));
|
|
39
|
+
expect(layers).toHaveLength(1);
|
|
40
|
+
expect(layers[0].type).toBe('text');
|
|
41
|
+
expect((layers[0] as TextLayer).content.text).toBe('Empty layout');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('emits one layer per child, in document order, at absolute positions', () => {
|
|
45
|
+
const layers = layoutBlock(
|
|
46
|
+
INPUT,
|
|
47
|
+
ctxWith([
|
|
48
|
+
child('box-1', 'rectangle', { x: '760', y: '390', width: '400', height: '300' }),
|
|
49
|
+
child('text-1', 'text', { x: '360', y: '380', width: '1200', height: '320' }, 'Welcome'),
|
|
50
|
+
]),
|
|
51
|
+
);
|
|
52
|
+
expect(layers.map((l) => l.id)).toEqual(['box-1', 'text-1']);
|
|
53
|
+
expect(layers[0].type).toBe('shape');
|
|
54
|
+
expect(layers[1].type).toBe('text');
|
|
55
|
+
expect((layers[1] as TextLayer).content.text).toBe('Welcome');
|
|
56
|
+
expect(layers[0].position).toMatchObject({ x: 760, y: 390 });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('layout container behavior', () => {
|
|
61
|
+
it('marks layout as a children-consuming container template', () => {
|
|
62
|
+
expect(isContainerTemplate('layout')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders a layout block through the template registry (getLayers)', async () => {
|
|
66
|
+
const { getLayers } = await import('../doc/getLayers.js');
|
|
67
|
+
const parent: Block = {
|
|
68
|
+
id: 'lay',
|
|
69
|
+
startTime: 0,
|
|
70
|
+
duration: 0,
|
|
71
|
+
audioSegment: 0,
|
|
72
|
+
template: 'layout',
|
|
73
|
+
children: [
|
|
74
|
+
child('text-1', 'text', { x: '360', y: '380', width: '1200', height: '320' }, 'Title'),
|
|
75
|
+
child('box-1', 'rectangle', { x: '760', y: '390', width: '400', height: '300' }),
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
const layers = getLayers(parent, {});
|
|
79
|
+
const types = new Set(layers.map((l: Layer) => l.type));
|
|
80
|
+
expect(types.has('text')).toBe(true);
|
|
81
|
+
expect(types.has('shape')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeLayoutLayers } from '../doc/templates/layoutLayout.js';
|
|
3
|
+
import { parseMarkdown } from '../markdown/parse.js';
|
|
4
|
+
import type { ShapeLayer, TextLayer, ImageLayer, PathLayer } from '../schemas/Doc.js';
|
|
5
|
+
|
|
6
|
+
const VP = { width: 1920, height: 1080 };
|
|
7
|
+
|
|
8
|
+
function child(id: string, template: string, params: Record<string, string> = {}, bodyMd = '') {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
template,
|
|
12
|
+
templateOverrides: params,
|
|
13
|
+
...(bodyMd ? { contents: parseMarkdown(bodyMd).children } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('computeLayoutLayers', () => {
|
|
18
|
+
it('maps a text child to an absolutely-positioned TextLayer (body text + style)', () => {
|
|
19
|
+
const { layers } = computeLayoutLayers(
|
|
20
|
+
[
|
|
21
|
+
child(
|
|
22
|
+
'text-1',
|
|
23
|
+
'text',
|
|
24
|
+
{
|
|
25
|
+
x: '100',
|
|
26
|
+
y: '200',
|
|
27
|
+
width: '600',
|
|
28
|
+
height: '120',
|
|
29
|
+
fontSize: '48',
|
|
30
|
+
align: 'center',
|
|
31
|
+
valign: 'middle',
|
|
32
|
+
color: '#1e293b',
|
|
33
|
+
},
|
|
34
|
+
'Hello **world**',
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
VP,
|
|
38
|
+
);
|
|
39
|
+
expect(layers).toHaveLength(1);
|
|
40
|
+
const t = layers[0] as TextLayer;
|
|
41
|
+
expect(t.type).toBe('text');
|
|
42
|
+
expect(t.id).toBe('text-1');
|
|
43
|
+
expect(t.position).toMatchObject({ x: 100, y: 200, width: 600, height: 120 });
|
|
44
|
+
expect(t.content.text).toBe('Hello world'); // plain projection of the body
|
|
45
|
+
expect(t.content.style.fontSize).toBe(48);
|
|
46
|
+
expect(t.content.style.textAlign).toBe('center');
|
|
47
|
+
expect(t.content.style.verticalAlign).toBe('middle');
|
|
48
|
+
expect(t.content.style.color).toBe('#1e293b');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('maps rectangle/circle children to ShapeLayers', () => {
|
|
52
|
+
const { layers } = computeLayoutLayers(
|
|
53
|
+
[
|
|
54
|
+
child('box-1', 'rectangle', {
|
|
55
|
+
x: '10',
|
|
56
|
+
y: '10',
|
|
57
|
+
width: '200',
|
|
58
|
+
height: '100',
|
|
59
|
+
fill: '#e0e7ff',
|
|
60
|
+
stroke: '#6366f1',
|
|
61
|
+
strokeWidth: '2',
|
|
62
|
+
borderRadius: '8',
|
|
63
|
+
}),
|
|
64
|
+
child('c-1', 'circle', { x: '300', y: '10', width: '120', height: '120' }),
|
|
65
|
+
],
|
|
66
|
+
VP,
|
|
67
|
+
);
|
|
68
|
+
const shapes = layers.filter((l): l is ShapeLayer => l.type === 'shape');
|
|
69
|
+
expect(shapes.map((s) => s.content.shape)).toEqual(['rect', 'circle']);
|
|
70
|
+
expect(shapes[0].content.fill).toBe('#e0e7ff');
|
|
71
|
+
expect(shapes[0].content.borderRadius).toBe(8);
|
|
72
|
+
expect(shapes[0].position).toMatchObject({ x: 10, y: 10, width: 200, height: 100 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('maps an image child to an ImageLayer and skips one missing src', () => {
|
|
76
|
+
const { layers, warnings } = computeLayoutLayers(
|
|
77
|
+
[
|
|
78
|
+
child('img-1', 'image', {
|
|
79
|
+
x: '0',
|
|
80
|
+
y: '0',
|
|
81
|
+
width: '320',
|
|
82
|
+
height: '240',
|
|
83
|
+
src: 'pic.png',
|
|
84
|
+
alt: 'A pic',
|
|
85
|
+
fit: 'cover',
|
|
86
|
+
}),
|
|
87
|
+
child('img-2', 'image', { x: '0', y: '0' }),
|
|
88
|
+
],
|
|
89
|
+
VP,
|
|
90
|
+
);
|
|
91
|
+
const imgs = layers.filter((l): l is ImageLayer => l.type === 'image');
|
|
92
|
+
expect(imgs).toHaveLength(1);
|
|
93
|
+
expect(imgs[0].content.src).toBe('pic.png');
|
|
94
|
+
expect(imgs[0].content.alt).toBe('A pic');
|
|
95
|
+
expect(imgs[0].content.fit).toBe('cover');
|
|
96
|
+
expect(warnings.some((w) => w.includes('img-2'))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('maps a named polygon to a PathLayer via shapePath', () => {
|
|
100
|
+
const { layers } = computeLayoutLayers(
|
|
101
|
+
[child('star-1', 'star', { x: '0', y: '0', width: '100', height: '100' })],
|
|
102
|
+
VP,
|
|
103
|
+
);
|
|
104
|
+
const p = layers[0] as PathLayer;
|
|
105
|
+
expect(p.type).toBe('path');
|
|
106
|
+
expect(p.content.d.startsWith('M')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('uses absolute positions (no fit-scaling) regardless of viewport', () => {
|
|
110
|
+
const children = [
|
|
111
|
+
child('text-1', 'text', { x: '100', y: '100', width: '200', height: '50' }, 'x'),
|
|
112
|
+
];
|
|
113
|
+
const small = computeLayoutLayers(children, { width: 200, height: 200 }).layers[0];
|
|
114
|
+
const large = computeLayoutLayers(children, { width: 4000, height: 4000 }).layers[0];
|
|
115
|
+
expect(small.position.x).toBe(100);
|
|
116
|
+
expect(large.position.x).toBe(100);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('preserves child document order as z-order', () => {
|
|
120
|
+
const { layers } = computeLayoutLayers(
|
|
121
|
+
[child('a', 'rectangle', { x: '0', y: '0' }), child('b', 'text', {}, 'top')],
|
|
122
|
+
VP,
|
|
123
|
+
);
|
|
124
|
+
expect(layers.map((l) => l.id)).toEqual(['a', 'b']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('warns and skips a child whose annotation is not a layer kind', () => {
|
|
128
|
+
const { layers, warnings } = computeLayoutLayers(
|
|
129
|
+
[child('weird', 'banana', { x: '0', y: '0' })],
|
|
130
|
+
VP,
|
|
131
|
+
);
|
|
132
|
+
expect(layers).toHaveLength(0);
|
|
133
|
+
expect(warnings.some((w) => w.includes('weird'))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('uses the supplied ink default for un-styled text and strokes', () => {
|
|
137
|
+
const { layers } = computeLayoutLayers(
|
|
138
|
+
[
|
|
139
|
+
child('t', 'text', { x: '0', y: '0' }, 'plain'),
|
|
140
|
+
child('l', 'line', { x: '0', y: '0', width: '100', height: '0' }),
|
|
141
|
+
],
|
|
142
|
+
VP,
|
|
143
|
+
{ ink: '#e0e0e0' },
|
|
144
|
+
);
|
|
145
|
+
const t = layers[0] as TextLayer;
|
|
146
|
+
const l = layers[1] as PathLayer;
|
|
147
|
+
expect(t.content.style.color).toBe('#e0e0e0');
|
|
148
|
+
expect(l.content.stroke).toBe('#e0e0e0');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('keeps the dark-slate ink default when no defaults are supplied (editor scene canvas)', () => {
|
|
152
|
+
const { layers } = computeLayoutLayers([child('t', 'text', { x: '0', y: '0' }, 'plain')], VP);
|
|
153
|
+
const t = layers[0] as TextLayer;
|
|
154
|
+
expect(t.content.style.color).toBe('#1e293b');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('never overrides author-specified color/stroke with the ink default', () => {
|
|
158
|
+
const { layers } = computeLayoutLayers(
|
|
159
|
+
[
|
|
160
|
+
child('t', 'text', { x: '0', y: '0', color: '#112233' }, 'styled'),
|
|
161
|
+
child('l', 'line', { x: '0', y: '0', width: '100', height: '0', stroke: '#445566' }),
|
|
162
|
+
],
|
|
163
|
+
VP,
|
|
164
|
+
{ ink: '#e0e0e0' },
|
|
165
|
+
);
|
|
166
|
+
expect((layers[0] as TextLayer).content.style.color).toBe('#112233');
|
|
167
|
+
expect((layers[1] as PathLayer).content.stroke).toBe('#445566');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
countNodes,
|
|
12
12
|
createDocument,
|
|
13
13
|
parseFrontmatter,
|
|
14
|
+
plainTextFromInlineHtml,
|
|
14
15
|
} from '../markdown/index';
|
|
15
16
|
import type {
|
|
16
17
|
MarkdownDocument,
|
|
@@ -870,3 +871,25 @@ describe('frontmatter in parseMarkdown', () => {
|
|
|
870
871
|
expect(doc.frontmatter).toBeUndefined();
|
|
871
872
|
});
|
|
872
873
|
});
|
|
874
|
+
|
|
875
|
+
describe('plainTextFromInlineHtml', () => {
|
|
876
|
+
it('strips inline tags, keeping text', () => {
|
|
877
|
+
expect(plainTextFromInlineHtml('<strong>bold</strong> and <em>italic</em>')).toBe(
|
|
878
|
+
'bold and italic',
|
|
879
|
+
);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('decodes entities', () => {
|
|
883
|
+
expect(plainTextFromInlineHtml('a & b <c>')).toBe('a & b <c>');
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('turns <br> and block tags into line breaks', () => {
|
|
887
|
+
expect(plainTextFromInlineHtml('one<br>two')).toBe('one\ntwo');
|
|
888
|
+
expect(plainTextFromInlineHtml('<p>one</p><p>two</p>')).toBe('one\ntwo');
|
|
889
|
+
expect(plainTextFromInlineHtml('<h2>Title</h2><p>body</p>')).toBe('Title\nbody');
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it('returns empty string for empty input', () => {
|
|
893
|
+
expect(plainTextFromInlineHtml('')).toBe('');
|
|
894
|
+
});
|
|
895
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
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('allows Squisq media URLs without allowing SVG or HTML data payloads', () => {
|
|
21
|
+
expect(sanitizeUrl('blob:http://localhost/abc', 'media')).toBe('blob:http://localhost/abc');
|
|
22
|
+
expect(sanitizeUrl('data:image/png;base64,AAA', 'media')).toBe('data:image/png;base64,AAA');
|
|
23
|
+
expect(sanitizeUrl('data:audio/webm;base64,AAA', 'media')).toBe('data:audio/webm;base64,AAA');
|
|
24
|
+
expect(sanitizeUrl('data:image/svg+xml;base64,AAA', 'media')).toBeNull();
|
|
25
|
+
expect(sanitizeUrl('data:text/html,<script>alert(1)</script>', 'media')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('removes dangerous tags and event handler attributes', () => {
|
|
29
|
+
const safe = sanitizeHtmlNodes(
|
|
30
|
+
parseHtmlToNodes('<div onclick="alert(1)">ok<script>alert(1)</script></div>'),
|
|
31
|
+
);
|
|
32
|
+
const [div] = elements(safe);
|
|
33
|
+
|
|
34
|
+
expect(div.tagName).toBe('div');
|
|
35
|
+
expect(div.attributes.onclick).toBeUndefined();
|
|
36
|
+
expect(div.children).toEqual([{ type: 'htmlText', value: 'ok' }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sanitizes link and image attributes inside raw HTML', () => {
|
|
40
|
+
const safe = sanitizeHtmlNodes(
|
|
41
|
+
parseHtmlToNodes(
|
|
42
|
+
'<a href="javascript:alert(1)" target="_blank">bad</a><img src="x.jpg" onerror="alert(1)" width="300">',
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
const [link, image] = elements(safe);
|
|
46
|
+
|
|
47
|
+
expect(link.tagName).toBe('a');
|
|
48
|
+
expect(link.attributes.href).toBeUndefined();
|
|
49
|
+
expect(link.attributes.target).toBe('_blank');
|
|
50
|
+
expect(link.attributes.rel).toBe('noopener noreferrer');
|
|
51
|
+
expect(image.tagName).toBe('img');
|
|
52
|
+
expect(image.attributes.src).toBe('x.jpg');
|
|
53
|
+
expect(image.attributes.onerror).toBeUndefined();
|
|
54
|
+
expect(image.attributes.width).toBe('300');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('unwraps unknown harmless containers while keeping their safe children', () => {
|
|
58
|
+
const safe = sanitizeHtmlNodes(parseHtmlToNodes('<custom><strong>kept</strong></custom>'));
|
|
59
|
+
const [strong] = elements(safe);
|
|
60
|
+
|
|
61
|
+
expect(strong.tagName).toBe('strong');
|
|
62
|
+
expect(strong.children).toEqual([{ type: 'htmlText', value: 'kept' }]);
|
|
63
|
+
});
|
|
64
|
+
});
|