@bendyline/squisq 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BsMkmzH8.d.ts → Doc-BBVGq1_9.d.ts} +94 -15
  4. package/dist/{ImageEditDoc-DvoeHMP9.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +1 -1
  5. package/dist/{chunk-4ZFRZBU2.js → chunk-5TYS5V3G.js} +866 -192
  6. package/dist/chunk-5TYS5V3G.js.map +1 -0
  7. package/dist/{chunk-7MQGNR76.js → chunk-A4O7GIWE.js} +2 -27
  8. package/dist/chunk-A4O7GIWE.js.map +1 -0
  9. package/dist/{chunk-OH43RPTP.js → chunk-BEQODJWV.js} +20 -3
  10. package/dist/chunk-BEQODJWV.js.map +1 -0
  11. package/dist/chunk-ET53IIEP.js +1 -0
  12. package/dist/chunk-ET53IIEP.js.map +1 -0
  13. package/dist/{chunk-KZNXZ5SM.js → chunk-GPLTCSXO.js} +18 -7
  14. package/dist/chunk-GPLTCSXO.js.map +1 -0
  15. package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
  16. package/dist/chunk-HWVFJAAH.js.map +1 -0
  17. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  18. package/dist/chunk-IMSCRLLV.js.map +1 -0
  19. package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
  20. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  21. package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
  22. package/dist/{chunk-RUDYOTA6.js → chunk-LTN6I7QW.js} +56 -1
  23. package/dist/chunk-LTN6I7QW.js.map +1 -0
  24. package/dist/{chunk-2OIBZYKP.js → chunk-P3K7NLHF.js} +2 -2
  25. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  26. package/dist/chunk-T5UK6YOB.js.map +1 -0
  27. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  28. package/dist/chunk-TQWLI6S2.js.map +1 -0
  29. package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
  30. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  31. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  32. package/dist/doc/index.d.ts +4 -4
  33. package/dist/doc/index.js +24 -9
  34. package/dist/generate/index.d.ts +2 -80
  35. package/dist/generate/index.js +2 -7
  36. package/dist/imageEdit/index.d.ts +4 -4
  37. package/dist/imageEdit/index.js +2 -2
  38. package/dist/index.d.ts +8 -8
  39. package/dist/index.js +41 -26
  40. package/dist/jsonForm/index.d.ts +42 -1
  41. package/dist/jsonForm/index.js +9 -1
  42. package/dist/markdown/index.d.ts +18 -2
  43. package/dist/markdown/index.js +5 -3
  44. package/dist/recommend/index.js +2 -2
  45. package/dist/schemas/index.d.ts +4 -4
  46. package/dist/schemas/index.js +3 -5
  47. package/dist/storage/index.d.ts +2 -2
  48. package/dist/storage/index.js +1 -1
  49. package/dist/story/index.d.ts +151 -13
  50. package/dist/story/index.js +24 -9
  51. package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  52. package/dist/timing/index.js +3 -5
  53. package/dist/transform/index.d.ts +2 -2
  54. package/dist/transform/index.js +3 -3
  55. package/dist/versions/index.d.ts +1 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/contentExtractor.test.ts +60 -0
  58. package/src/__tests__/coverBlock.test.ts +79 -3
  59. package/src/__tests__/customTemplateSample.test.ts +63 -2
  60. package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
  61. package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
  62. package/src/__tests__/inputDescriptors.test.ts +162 -0
  63. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  64. package/src/__tests__/markdown.test.ts +64 -0
  65. package/src/__tests__/markdownSanitize.test.ts +15 -0
  66. package/src/__tests__/markdownToDoc.test.ts +173 -2
  67. package/src/__tests__/mediaAnnotations.test.ts +27 -0
  68. package/src/__tests__/shapeGeometry.test.ts +37 -0
  69. package/src/__tests__/structuredData.test.ts +73 -3
  70. package/src/__tests__/templateMetadata.test.ts +11 -1
  71. package/src/__tests__/templates.test.ts +112 -0
  72. package/src/__tests__/validateDoc.test.ts +160 -0
  73. package/src/doc/annotationBlocks.ts +117 -0
  74. package/src/doc/customTemplatesFrontmatter.ts +7 -1
  75. package/src/doc/customThemesFrontmatter.ts +6 -1
  76. package/src/doc/docToMarkdown.ts +78 -29
  77. package/src/doc/getLayers.ts +11 -2
  78. package/src/doc/index.ts +12 -0
  79. package/src/doc/markdownToDoc.ts +80 -3
  80. package/src/doc/mediaAnnotations.ts +15 -33
  81. package/src/doc/standaloneAnnotation.ts +58 -0
  82. package/src/doc/structuredData.ts +92 -21
  83. package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
  84. package/src/doc/templates/comparisonBar.ts +8 -1
  85. package/src/doc/templates/coverBlock.ts +78 -15
  86. package/src/doc/templates/customTemplate.ts +10 -4
  87. package/src/doc/templates/diagramBlock.ts +2 -2
  88. package/src/doc/templates/drawingBlock.ts +2 -2
  89. package/src/doc/templates/factCard.ts +4 -2
  90. package/src/doc/templates/imageWithCaption.ts +10 -8
  91. package/src/doc/templates/index.ts +12 -2
  92. package/src/doc/templates/inputDescriptors.ts +432 -0
  93. package/src/doc/templates/photoGrid.ts +16 -39
  94. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
  95. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  96. package/src/doc/templates/videoWithCaption.ts +15 -14
  97. package/src/doc/utils/nearest.ts +59 -0
  98. package/src/doc/utils/shapeGeometry.ts +90 -2
  99. package/src/doc/validate.ts +153 -54
  100. package/src/generate/contentExtractor.ts +148 -35
  101. package/src/generate/index.ts +0 -3
  102. package/src/jsonForm/index.ts +2 -0
  103. package/src/jsonForm/tokens.ts +78 -0
  104. package/src/markdown/annotationCoercion.ts +1 -1
  105. package/src/markdown/attrTokens.ts +19 -0
  106. package/src/markdown/convert.ts +11 -1
  107. package/src/markdown/index.ts +2 -1
  108. package/src/markdown/sanitize.ts +22 -2
  109. package/src/markdown/stringify.ts +12 -3
  110. package/src/markdown/utils.ts +62 -1
  111. package/src/schemas/CustomTemplates.ts +56 -4
  112. package/src/schemas/Doc.ts +39 -41
  113. package/src/schemas/themeCompile.ts +1 -1
  114. package/src/storage/ContentContainer.ts +6 -2
  115. package/src/transform/types.ts +1 -1
  116. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  117. package/dist/chunk-3E5F2XMR.js.map +0 -1
  118. package/dist/chunk-4AKUQPEE.js.map +0 -1
  119. package/dist/chunk-4X3JQXNM.js +0 -35
  120. package/dist/chunk-4X3JQXNM.js.map +0 -1
  121. package/dist/chunk-4ZFRZBU2.js.map +0 -1
  122. package/dist/chunk-7MQGNR76.js.map +0 -1
  123. package/dist/chunk-FBKP5CAO.js +0 -145
  124. package/dist/chunk-FBKP5CAO.js.map +0 -1
  125. package/dist/chunk-FR2RBTKO.js.map +0 -1
  126. package/dist/chunk-K32VJONL.js.map +0 -1
  127. package/dist/chunk-KZNXZ5SM.js.map +0 -1
  128. package/dist/chunk-OH43RPTP.js.map +0 -1
  129. package/dist/chunk-OKJOQXK4.js.map +0 -1
  130. package/dist/chunk-RUDYOTA6.js.map +0 -1
  131. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  132. package/src/generate/slideshowGenerator.ts +0 -280
  133. /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
  134. /package/dist/{chunk-2OIBZYKP.js.map → chunk-P3K7NLHF.js.map} +0 -0
  135. /package/dist/{chunk-LRCV7N7F.js.map → chunk-UY7KGQ5R.js.map} +0 -0
@@ -11,8 +11,12 @@ import { describe, it, expect } from 'vitest';
11
11
  import { markdownToDoc } from '../doc/markdownToDoc';
12
12
  import { expandDocBlocks } from '../doc/templates/index';
13
13
  import { parseMarkdown } from '../markdown/parse';
14
- import { writeCustomTemplatesToFrontmatter } from '../doc/customTemplatesFrontmatter';
15
- import type { CustomTemplateDefinition } from '../schemas/CustomTemplates.js';
14
+ import {
15
+ writeCustomTemplatesToFrontmatter,
16
+ readCustomTemplatesFromFrontmatter,
17
+ FRONTMATTER_CUSTOM_TEMPLATES_KEY,
18
+ } from '../doc/customTemplatesFrontmatter';
19
+ import type { CustomTemplateDefinition, LayerRepeat } from '../schemas/CustomTemplates.js';
16
20
  import type { TextLayer } from '../schemas/Doc.js';
17
21
  import { VIEWPORT_PRESETS } from '../schemas/Viewport.js';
18
22
 
@@ -80,4 +84,61 @@ describe('Custom template pipeline (markdown → expanded layers)', () => {
80
84
  expect(layers[0].position.x).toBe('6%');
81
85
  expect(layers[0].position.width).toBe('88%');
82
86
  });
87
+
88
+ it('resolves {attr:key} against a block heading annotation through the pipeline', () => {
89
+ const attrDef: CustomTemplateDefinition = {
90
+ name: 'badge',
91
+ label: 'Badge',
92
+ viewport: { width: 1920, height: 1080 },
93
+ layers: [
94
+ {
95
+ id: 'badge-role',
96
+ type: 'text',
97
+ position: { x: '6%', y: '20%', width: '88%' },
98
+ content: { text: 'Role: {attr:role|none}', style: { fontSize: 48, color: '#000' } },
99
+ },
100
+ ],
101
+ };
102
+ const payload = writeCustomTemplatesToFrontmatter([attrDef]);
103
+ const md = `---
104
+ squisq-custom-templates: "${payload}"
105
+ ---
106
+
107
+ # Team {[badge role=lead]}
108
+
109
+ Body.
110
+ `;
111
+ const doc = markdownToDoc(parseMarkdown(md));
112
+ const blocks = expandDocBlocks(doc.blocks, { customTemplates: doc.customTemplates });
113
+ const layers = blocks[0].layers ?? [];
114
+ expect((layers[0] as TextLayer).content.text).toBe('Role: lead');
115
+ });
116
+ });
117
+
118
+ describe('Custom template codec — repeat round-trip', () => {
119
+ it('preserves a layer.repeat directive through write → read (passes through unmapped)', () => {
120
+ const repeat: LayerRepeat = { source: 'images', direction: 'row', gap: 12, max: 4 };
121
+ const def: CustomTemplateDefinition = {
122
+ name: 'gallery',
123
+ label: 'Gallery',
124
+ viewport: { width: 1920, height: 1080 },
125
+ layers: [
126
+ {
127
+ id: 'thumb',
128
+ type: 'image',
129
+ position: { x: '0%', y: '0%', width: '25%', height: '25%' },
130
+ content: { src: '{item:src}', alt: '{item:label}' },
131
+ repeat,
132
+ },
133
+ ],
134
+ };
135
+ const payload = writeCustomTemplatesToFrontmatter([def]);
136
+ expect(payload).toBeDefined();
137
+ const round = readCustomTemplatesFromFrontmatter({
138
+ [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: JSON.parse(payload as string),
139
+ });
140
+ expect(round).toBeDefined();
141
+ const layer = round?.[0].layers[0] as (typeof def.layers)[number];
142
+ expect(layer.repeat).toEqual(repeat);
143
+ });
83
144
  });
@@ -71,6 +71,37 @@ describe('writeCustomTemplatesToFrontmatter → readCustomTemplatesFromFrontmatt
71
71
  expect(out![0]).toEqual(heroDef);
72
72
  });
73
73
 
74
+ it('default output is compact (single line) and byte-unchanged', () => {
75
+ const payload = writeCustomTemplatesToFrontmatter([heroDef])!;
76
+ expect(payload).not.toContain('\n');
77
+ // Explicit pretty:false must match the historical default exactly.
78
+ expect(writeCustomTemplatesToFrontmatter([heroDef], { pretty: false })).toBe(payload);
79
+ });
80
+
81
+ it('pretty:true emits multi-line JSON that still reads back', () => {
82
+ const payload = writeCustomTemplatesToFrontmatter([heroDef], { pretty: true })!;
83
+ expect(payload).toContain('\n');
84
+ // Same compact key codec, just pretty-printed.
85
+ expect(payload).toContain('"lb": "Hero"');
86
+ const fm = { [FRONTMATTER_CUSTOM_TEMPLATES_KEY]: payload };
87
+ const out = readCustomTemplatesFromFrontmatter(fm);
88
+ expect(out).toHaveLength(1);
89
+ expect(out![0]).toEqual(heroDef);
90
+ });
91
+
92
+ it('a hand-written block-scalar frontmatter parses back into the definition', () => {
93
+ const payload = writeCustomTemplatesToFrontmatter([heroDef], { pretty: true })!;
94
+ const body = payload
95
+ .split('\n')
96
+ .map((l) => ` ${l}`)
97
+ .join('\n');
98
+ const md = `---\n${FRONTMATTER_CUSTOM_TEMPLATES_KEY}: |-\n${body}\n---\n\n# Hi\n`;
99
+ const parsed = parseMarkdown(md);
100
+ const out = readCustomTemplatesFromFrontmatter(parsed.frontmatter);
101
+ expect(out).toHaveLength(1);
102
+ expect(out![0]).toEqual(heroDef);
103
+ });
104
+
74
105
  it('is markedly smaller than the legacy base64 form', () => {
75
106
  const compact = writeCustomTemplatesToFrontmatter([heroDef])!;
76
107
  const legacyBase64 = btoa(unescape(encodeURIComponent(JSON.stringify([{ ...heroDef }]))));
@@ -37,6 +37,33 @@ describe('writeCustomThemesToFrontmatter → readCustomThemesFromFrontmatter', (
37
37
  expect(out![0]).toEqual(brand);
38
38
  });
39
39
 
40
+ it('default output is compact (single line) and byte-unchanged', () => {
41
+ const payload = writeCustomThemesToFrontmatter([brand])!;
42
+ expect(payload).not.toContain('\n');
43
+ expect(writeCustomThemesToFrontmatter([brand], { pretty: false })).toBe(payload);
44
+ });
45
+
46
+ it('pretty:true emits multi-line JSON that still reads back', () => {
47
+ const payload = writeCustomThemesToFrontmatter([brand], { pretty: true })!;
48
+ expect(payload).toContain('\n');
49
+ const out = readCustomThemesFromFrontmatter({ [FRONTMATTER_CUSTOM_THEMES_KEY]: payload });
50
+ expect(out).toHaveLength(1);
51
+ expect(out![0]).toEqual(brand);
52
+ });
53
+
54
+ it('a hand-written block-scalar frontmatter parses back into the theme', () => {
55
+ const payload = writeCustomThemesToFrontmatter([brand], { pretty: true })!;
56
+ const body = payload
57
+ .split('\n')
58
+ .map((l) => ` ${l}`)
59
+ .join('\n');
60
+ const md = `---\n${FRONTMATTER_CUSTOM_THEMES_KEY}: |-\n${body}\n---\n\n# Hi\n`;
61
+ const parsed = parseMarkdown(md);
62
+ const out = readCustomThemesFromFrontmatter(parsed.frontmatter);
63
+ expect(out).toHaveLength(1);
64
+ expect(out![0]).toEqual(brand);
65
+ });
66
+
40
67
  it('round-trips multiple themes keyed by id', () => {
41
68
  const second = compileTheme({
42
69
  id: 'second',
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ BASE_INPUT_DESCRIPTORS,
4
+ TEMPLATE_INPUT_DESCRIPTORS,
5
+ coerceTemplateParams,
6
+ lintTemplateParams,
7
+ } from '../doc/templates/inputDescriptors';
8
+ import { templateRegistry } from '../doc/templates/index';
9
+
10
+ describe('coerceTemplateParams', () => {
11
+ it('coerces number keys, leaving the raw string on failure', () => {
12
+ expect(coerceTemplateParams('map', { zoom: '9' }).input).toEqual({ zoom: 9 });
13
+
14
+ const bad = coerceTemplateParams('map', { zoom: 'abc' });
15
+ expect(bad.input).toEqual({ zoom: 'abc' });
16
+ expect(bad.warnings).toHaveLength(1);
17
+ expect(bad.warnings[0]).toContain('zoom');
18
+ });
19
+
20
+ it('coerces boolean keys (true/empty → true, false → false)', () => {
21
+ const { input } = coerceTemplateParams('imageWithCaption', { isTitle: 'true' });
22
+ expect(input.isTitle).toBe(true);
23
+ // A bare flag `{[… isTitle]}` parses to an empty value → true.
24
+ expect(coerceTemplateParams('imageWithCaption', { isTitle: '' }).input.isTitle).toBe(true);
25
+ expect(coerceTemplateParams('imageWithCaption', { isTitle: 'false' }).input.isTitle).toBe(
26
+ false,
27
+ );
28
+
29
+ const bad = coerceTemplateParams('imageWithCaption', { isTitle: 'maybe' });
30
+ expect(bad.input.isTitle).toBe('maybe');
31
+ expect(bad.warnings).toHaveLength(1);
32
+ });
33
+
34
+ it('coerces latLng into { lat, lng }, warning on malformed input', () => {
35
+ expect(coerceTemplateParams('map', { center: '47.6,-122.3' }).input).toEqual({
36
+ center: { lat: 47.6, lng: -122.3 },
37
+ });
38
+
39
+ const bad = coerceTemplateParams('map', { center: 'nope' });
40
+ expect(bad.input).toEqual({ center: 'nope' });
41
+ expect(bad.warnings[0]).toContain('center');
42
+ });
43
+
44
+ it('coerces labeledPair into { label, sublabel? }', () => {
45
+ expect(coerceTemplateParams('twoColumn', { left: 'Espresso|Bold' }).input).toEqual({
46
+ left: { label: 'Espresso', sublabel: 'Bold' },
47
+ });
48
+ // No separator → label only, no sublabel key.
49
+ expect(coerceTemplateParams('twoColumn', { right: 'Filter' }).input).toEqual({
50
+ right: { label: 'Filter' },
51
+ });
52
+ });
53
+
54
+ it('coerces stringList by splitting on commas and trimming', () => {
55
+ expect(coerceTemplateParams('photoGrid', { images: 'a.jpg, b.jpg ,c.jpg' }).input).toEqual({
56
+ images: ['a.jpg', 'b.jpg', 'c.jpg'],
57
+ });
58
+ });
59
+
60
+ it('passes unknown keys through unchanged as strings (never lossy)', () => {
61
+ const { input, warnings } = coerceTemplateParams('map', { wobble: 'yes', center: '1,2' });
62
+ expect(input.wobble).toBe('yes');
63
+ expect(input.center).toEqual({ lat: 1, lng: 2 });
64
+ expect(warnings).toEqual([]);
65
+ });
66
+
67
+ it('passes everything through for a template with no descriptors', () => {
68
+ expect(coerceTemplateParams('sectionHeader', { anything: '5' }).input).toEqual({
69
+ anything: '5',
70
+ });
71
+ });
72
+
73
+ it('resolves legacy template aliases before coercing', () => {
74
+ expect(coerceTemplateParams('mapBlock', { zoom: '7' }).input).toEqual({ zoom: 7 });
75
+ });
76
+
77
+ it('coerces shared base inputs (useTopLayer boolean)', () => {
78
+ expect(coerceTemplateParams('title', { useTopLayer: 'false' }).input.useTopLayer).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('lintTemplateParams', () => {
83
+ it('returns [] for a template without descriptors', () => {
84
+ expect(lintTemplateParams('sectionHeader', { foo: 'bar' })).toEqual([]);
85
+ });
86
+
87
+ it('flags an unknown input with a did-you-mean suggestion', () => {
88
+ const findings = lintTemplateParams('map', { centre: '1,2' });
89
+ const unknown = findings.find((f) => f.kind === 'unknown-input');
90
+ expect(unknown).toBeDefined();
91
+ expect(unknown!.key).toBe('centre');
92
+ expect(unknown!.suggestion).toBe('center');
93
+ expect(unknown!.message).toContain('Did you mean "center"?');
94
+ });
95
+
96
+ it('does not flag block-meta keys carried on the same annotation', () => {
97
+ const findings = lintTemplateParams('title', { duration: '8', transition: 'fade' });
98
+ expect(findings.filter((f) => f.kind === 'unknown-input')).toEqual([]);
99
+ });
100
+
101
+ it('flags a value outside a closed enum', () => {
102
+ const findings = lintTemplateParams('map', { center: '1,2', mapStyle: 'nope' });
103
+ const invalid = findings.find((f) => f.kind === 'invalid-input-value');
104
+ expect(invalid).toBeDefined();
105
+ expect(invalid!.key).toBe('mapStyle');
106
+ expect(invalid!.message).toContain('terrain');
107
+ });
108
+
109
+ it('flags a value that fails coercion (bad latLng)', () => {
110
+ const findings = lintTemplateParams('map', { center: 'garbage' });
111
+ expect(findings.some((f) => f.kind === 'invalid-input-value' && f.key === 'center')).toBe(true);
112
+ });
113
+
114
+ it('flags missing required inputs', () => {
115
+ const findings = lintTemplateParams('twoColumn', {});
116
+ const missing = findings
117
+ .filter((f) => f.kind === 'missing-input')
118
+ .map((f) => f.key)
119
+ .sort();
120
+ expect(missing).toEqual(['left', 'right']);
121
+ });
122
+
123
+ it('does not flag a required input that is present', () => {
124
+ const findings = lintTemplateParams('map', { center: '1,2' });
125
+ expect(findings.filter((f) => f.kind === 'missing-input')).toEqual([]);
126
+ });
127
+
128
+ it('accepts a valid enum value', () => {
129
+ const findings = lintTemplateParams('map', { center: '1,2', mapStyle: 'satellite' });
130
+ expect(findings).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe('descriptor registry shape', () => {
135
+ it('base descriptors are declared with unique keys', () => {
136
+ const keys = BASE_INPUT_DESCRIPTORS.map((d) => d.key);
137
+ expect(new Set(keys).size).toBe(keys.length);
138
+ });
139
+
140
+ it('has no orphan descriptors — every keyed template exists in the registry', () => {
141
+ for (const template of Object.keys(TEMPLATE_INPUT_DESCRIPTORS)) {
142
+ expect(templateRegistry, `orphan descriptor for "${template}"`).toHaveProperty(template);
143
+ }
144
+ });
145
+
146
+ it('every template descriptor has unique keys and a description', () => {
147
+ for (const [template, descriptors] of Object.entries(TEMPLATE_INPUT_DESCRIPTORS)) {
148
+ const keys = descriptors.map((d) => d.key);
149
+ expect(new Set(keys).size, `duplicate key in ${template}`).toBe(keys.length);
150
+ for (const d of descriptors) {
151
+ expect(d.description.length, `empty description for ${template}.${d.key}`).toBeGreaterThan(
152
+ 0,
153
+ );
154
+ // values / valueHint are mutually exclusive.
155
+ expect(
156
+ !(d.values && d.valueHint),
157
+ `${template}.${d.key} has both values and valueHint`,
158
+ ).toBe(true);
159
+ }
160
+ }
161
+ });
162
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildJsonFormTokens, resolveJsonFormTheme } from '../jsonForm/index.js';
3
+ import { DARK_SURFACE, LIGHT_SURFACE, DEFAULT_THEME, applySurface } from '../schemas/index.js';
4
+
5
+ const EXPECTED_SUFFIXES = [
6
+ 'bg',
7
+ 'text',
8
+ 'muted',
9
+ 'primary',
10
+ 'accent',
11
+ 'warning',
12
+ 'border',
13
+ 'input-bg',
14
+ 'title-font',
15
+ 'body-font',
16
+ 'mono-font',
17
+ 'radius',
18
+ ];
19
+
20
+ describe('buildJsonFormTokens', () => {
21
+ it('emits the full token set for the viewer prefix', () => {
22
+ const tokens = buildJsonFormTokens(DEFAULT_THEME, LIGHT_SURFACE, { prefix: '--squisq-json' });
23
+ for (const suffix of EXPECTED_SUFFIXES) {
24
+ expect(tokens).toHaveProperty(`--squisq-json-${suffix}`);
25
+ }
26
+ expect(Object.keys(tokens)).toHaveLength(EXPECTED_SUFFIXES.length);
27
+ });
28
+
29
+ it('emits the full token set for the editor prefix', () => {
30
+ const tokens = buildJsonFormTokens(DEFAULT_THEME, LIGHT_SURFACE, {
31
+ prefix: '--squisq-jsonform',
32
+ });
33
+ for (const suffix of EXPECTED_SUFFIXES) {
34
+ expect(tokens).toHaveProperty(`--squisq-jsonform-${suffix}`);
35
+ }
36
+ expect(Object.keys(tokens)).toHaveLength(EXPECTED_SUFFIXES.length);
37
+ });
38
+
39
+ it('resolves colors from the applied surface (light vs dark differ)', () => {
40
+ const light = buildJsonFormTokens(DEFAULT_THEME, LIGHT_SURFACE, { prefix: '--squisq-json' });
41
+ const dark = buildJsonFormTokens(DEFAULT_THEME, DARK_SURFACE, { prefix: '--squisq-json' });
42
+ const lightTheme = applySurface(DEFAULT_THEME, LIGHT_SURFACE);
43
+ const darkTheme = applySurface(DEFAULT_THEME, DARK_SURFACE);
44
+ expect(light['--squisq-json-bg']).toBe(lightTheme.colors.background);
45
+ expect(dark['--squisq-json-bg']).toBe(darkTheme.colors.background);
46
+ // The two surfaces should not produce an identical background.
47
+ expect(light['--squisq-json-bg']).not.toBe(dark['--squisq-json-bg']);
48
+ });
49
+
50
+ it('falls back to DEFAULT_THEME when theme is undefined', () => {
51
+ const tokens = buildJsonFormTokens(undefined, undefined, { prefix: '--squisq-json' });
52
+ expect(tokens['--squisq-json-bg']).toBe(DEFAULT_THEME.colors.background);
53
+ });
54
+
55
+ it('does not apply surface when surface is undefined', () => {
56
+ const tokens = buildJsonFormTokens(DEFAULT_THEME, undefined, { prefix: '--squisq-json' });
57
+ expect(tokens['--squisq-json-bg']).toBe(DEFAULT_THEME.colors.background);
58
+ expect(tokens['--squisq-json-radius']).toBe(`${DEFAULT_THEME.style.borderRadius ?? 8}px`);
59
+ });
60
+
61
+ it('is pure — no window/matchMedia access required', () => {
62
+ // Runs fine even if window is momentarily undefined-like; the function
63
+ // never touches globals.
64
+ expect(() =>
65
+ buildJsonFormTokens(DEFAULT_THEME, DARK_SURFACE, { prefix: '--squisq-json' }),
66
+ ).not.toThrow();
67
+ });
68
+ });
69
+
70
+ describe('resolveJsonFormTheme', () => {
71
+ it('applies the surface to the theme', () => {
72
+ expect(resolveJsonFormTheme(DEFAULT_THEME, DARK_SURFACE)).toEqual(
73
+ applySurface(DEFAULT_THEME, DARK_SURFACE),
74
+ );
75
+ });
76
+
77
+ it('returns the base theme untouched when surface is undefined', () => {
78
+ expect(resolveJsonFormTheme(DEFAULT_THEME, undefined)).toBe(DEFAULT_THEME);
79
+ });
80
+
81
+ it('falls back to DEFAULT_THEME when theme is undefined', () => {
82
+ expect(resolveJsonFormTheme(undefined, undefined)).toBe(DEFAULT_THEME);
83
+ });
84
+ });
@@ -11,6 +11,7 @@ import {
11
11
  countNodes,
12
12
  createDocument,
13
13
  parseFrontmatter,
14
+ setFrontmatterValues,
14
15
  plainTextFromInlineHtml,
15
16
  } from '../markdown/index';
16
17
  import type {
@@ -839,6 +840,69 @@ describe('parseFrontmatter', () => {
839
840
  expect(parseFrontmatter('')).toBeNull();
840
841
  expect(parseFrontmatter(' \n ')).toBeNull();
841
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
+ });
842
906
  });
843
907
 
844
908
  describe('frontmatter in parseMarkdown', () => {
@@ -17,6 +17,21 @@ describe('markdown HTML sanitization', () => {
17
17
  expect(sanitizeUrl('data:text/html,<script>alert(1)</script>', 'link')).toBeNull();
18
18
  });
19
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
+
20
35
  it('allows Squisq media URLs without allowing SVG or HTML data payloads', () => {
21
36
  expect(sanitizeUrl('blob:http://localhost/abc', 'media')).toBe('blob:http://localhost/abc');
22
37
  expect(sanitizeUrl('data:image/png;base64,AAA', 'media')).toBe('data:image/png;base64,AAA');