@bendyline/squisq 1.5.0 → 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.
Files changed (124) 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-KZNXZ5SM.js → chunk-6ATE2PSM.js} +18 -7
  6. package/dist/chunk-6ATE2PSM.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-ET53IIEP.js +1 -0
  10. package/dist/chunk-ET53IIEP.js.map +1 -0
  11. package/dist/{chunk-OKJOQXK4.js → chunk-HWVFJAAH.js} +38 -2
  12. package/dist/chunk-HWVFJAAH.js.map +1 -0
  13. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  14. package/dist/chunk-IMSCRLLV.js.map +1 -0
  15. package/dist/{chunk-4AKUQPEE.js → chunk-KKNUBQ6Y.js} +1 -1
  16. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  17. package/dist/{chunk-TVSGLUID.js → chunk-LDQ2HJIX.js} +3 -3
  18. package/dist/{chunk-4ZFRZBU2.js → chunk-LH7I6SH7.js} +777 -151
  19. package/dist/chunk-LH7I6SH7.js.map +1 -0
  20. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  21. package/dist/chunk-T5UK6YOB.js.map +1 -0
  22. package/dist/{chunk-OH43RPTP.js → chunk-TEEEILMP.js} +12 -2
  23. package/dist/chunk-TEEEILMP.js.map +1 -0
  24. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  25. package/dist/chunk-TQWLI6S2.js.map +1 -0
  26. package/dist/{chunk-LRCV7N7F.js → chunk-UY7KGQ5R.js} +2 -2
  27. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  28. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  29. package/dist/doc/index.d.ts +4 -4
  30. package/dist/doc/index.js +16 -7
  31. package/dist/generate/index.d.ts +2 -80
  32. package/dist/generate/index.js +2 -7
  33. package/dist/imageEdit/index.d.ts +4 -4
  34. package/dist/index.d.ts +8 -8
  35. package/dist/index.js +32 -23
  36. package/dist/jsonForm/index.d.ts +42 -1
  37. package/dist/jsonForm/index.js +9 -1
  38. package/dist/markdown/index.d.ts +18 -2
  39. package/dist/markdown/index.js +5 -3
  40. package/dist/recommend/index.js +2 -2
  41. package/dist/schemas/index.d.ts +4 -4
  42. package/dist/schemas/index.js +3 -5
  43. package/dist/storage/index.d.ts +2 -2
  44. package/dist/storage/index.js +1 -1
  45. package/dist/story/index.d.ts +125 -11
  46. package/dist/story/index.js +16 -7
  47. package/dist/{themeLibrary-DQQKuRMx.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  48. package/dist/timing/index.js +3 -5
  49. package/dist/transform/index.d.ts +2 -2
  50. package/dist/transform/index.js +3 -3
  51. package/dist/versions/index.d.ts +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/contentExtractor.test.ts +60 -0
  54. package/src/__tests__/coverBlock.test.ts +46 -1
  55. package/src/__tests__/customTemplateSample.test.ts +63 -2
  56. package/src/__tests__/customTemplatesFrontmatter.test.ts +31 -0
  57. package/src/__tests__/customThemesFrontmatter.test.ts +27 -0
  58. package/src/__tests__/inputDescriptors.test.ts +162 -0
  59. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  60. package/src/__tests__/markdown.test.ts +64 -0
  61. package/src/__tests__/markdownSanitize.test.ts +15 -0
  62. package/src/__tests__/markdownToDoc.test.ts +168 -0
  63. package/src/__tests__/mediaAnnotations.test.ts +27 -0
  64. package/src/__tests__/structuredData.test.ts +73 -3
  65. package/src/__tests__/templateMetadata.test.ts +11 -1
  66. package/src/__tests__/templates.test.ts +112 -0
  67. package/src/__tests__/validateDoc.test.ts +160 -0
  68. package/src/doc/annotationBlocks.ts +117 -0
  69. package/src/doc/customTemplatesFrontmatter.ts +7 -1
  70. package/src/doc/customThemesFrontmatter.ts +6 -1
  71. package/src/doc/docToMarkdown.ts +31 -1
  72. package/src/doc/getLayers.ts +11 -2
  73. package/src/doc/index.ts +12 -0
  74. package/src/doc/markdownToDoc.ts +80 -3
  75. package/src/doc/mediaAnnotations.ts +15 -33
  76. package/src/doc/standaloneAnnotation.ts +58 -0
  77. package/src/doc/structuredData.ts +92 -21
  78. package/src/doc/templates/__tests__/customTemplate.test.ts +21 -2
  79. package/src/doc/templates/comparisonBar.ts +8 -1
  80. package/src/doc/templates/coverBlock.ts +29 -2
  81. package/src/doc/templates/customTemplate.ts +10 -4
  82. package/src/doc/templates/factCard.ts +4 -2
  83. package/src/doc/templates/imageWithCaption.ts +10 -8
  84. package/src/doc/templates/index.ts +12 -2
  85. package/src/doc/templates/inputDescriptors.ts +432 -0
  86. package/src/doc/templates/photoGrid.ts +16 -39
  87. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +267 -1
  88. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  89. package/src/doc/templates/videoWithCaption.ts +15 -14
  90. package/src/doc/utils/nearest.ts +59 -0
  91. package/src/doc/validate.ts +153 -54
  92. package/src/generate/contentExtractor.ts +148 -35
  93. package/src/generate/index.ts +0 -3
  94. package/src/jsonForm/index.ts +2 -0
  95. package/src/jsonForm/tokens.ts +78 -0
  96. package/src/markdown/annotationCoercion.ts +1 -1
  97. package/src/markdown/attrTokens.ts +19 -0
  98. package/src/markdown/index.ts +2 -1
  99. package/src/markdown/sanitize.ts +22 -2
  100. package/src/markdown/stringify.ts +12 -3
  101. package/src/markdown/utils.ts +62 -1
  102. package/src/schemas/CustomTemplates.ts +56 -4
  103. package/src/schemas/Doc.ts +39 -41
  104. package/src/schemas/themeCompile.ts +1 -1
  105. package/src/storage/ContentContainer.ts +6 -2
  106. package/src/transform/types.ts +1 -1
  107. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  108. package/dist/chunk-3E5F2XMR.js.map +0 -1
  109. package/dist/chunk-4AKUQPEE.js.map +0 -1
  110. package/dist/chunk-4X3JQXNM.js +0 -35
  111. package/dist/chunk-4X3JQXNM.js.map +0 -1
  112. package/dist/chunk-4ZFRZBU2.js.map +0 -1
  113. package/dist/chunk-7MQGNR76.js.map +0 -1
  114. package/dist/chunk-FBKP5CAO.js +0 -145
  115. package/dist/chunk-FBKP5CAO.js.map +0 -1
  116. package/dist/chunk-FR2RBTKO.js.map +0 -1
  117. package/dist/chunk-K32VJONL.js.map +0 -1
  118. package/dist/chunk-KZNXZ5SM.js.map +0 -1
  119. package/dist/chunk-OH43RPTP.js.map +0 -1
  120. package/dist/chunk-OKJOQXK4.js.map +0 -1
  121. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  122. package/src/generate/slideshowGenerator.ts +0 -280
  123. /package/dist/{chunk-TVSGLUID.js.map → chunk-LDQ2HJIX.js.map} +0 -0
  124. /package/dist/{chunk-LRCV7N7F.js.map → chunk-UY7KGQ5R.js.map} +0 -0
@@ -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');
@@ -250,6 +250,31 @@ describe('docToMarkdown (round-trip)', () => {
250
250
  expect(stripPositions(md2)).toEqual(stripPositions(md));
251
251
  });
252
252
 
253
+ it('round-trips inline coerced template params, keeping overrides raw', () => {
254
+ const input = '# Downtown {[map center="47.6,-122.3" zoom=9 mapStyle="road"]}\n\nSome text.\n';
255
+ const md = parseMarkdown(input);
256
+ const doc = markdownToDoc(md, { generateCoverBlock: false });
257
+
258
+ // The overrides must stay RAW strings — coercion only ever touches the
259
+ // ephemeral render input, never the stored block, so round-trips are lossless.
260
+ expect(doc.blocks[0].templateOverrides).toEqual({
261
+ center: '47.6,-122.3',
262
+ zoom: '9',
263
+ mapStyle: 'road',
264
+ });
265
+
266
+ // Byte-stable: one full cycle reaches a fixed point that a second cycle
267
+ // reproduces exactly.
268
+ const once = stringifyMarkdown(docToMarkdown(doc));
269
+ const twice = stringifyMarkdown(
270
+ docToMarkdown(markdownToDoc(parseMarkdown(once), { generateCoverBlock: false })),
271
+ );
272
+ expect(twice).toBe(once);
273
+ // And the annotation with its raw values survives serialization.
274
+ expect(once).toContain('center=47.6,-122.3');
275
+ expect(once).toContain('zoom=9');
276
+ });
277
+
253
278
  it('round-trips preamble content', () => {
254
279
  const input = 'Preamble text\n\n# Heading\n\nBody\n';
255
280
  const md = parseMarkdown(input);
@@ -379,6 +404,51 @@ describe('template annotation in markdownToDoc', () => {
379
404
  expect(output).toContain('{transition=wipe transitionDuration=0.8 transitionDirection=left}');
380
405
  });
381
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
+
382
452
  it('nested headings preserve their own annotations', () => {
383
453
  const input = '# Chapter {[title]}\n\nIntro\n\n## Section {[chart]}\n\nData';
384
454
  const md = parseMarkdown(input);
@@ -784,3 +854,101 @@ describe('auto template picking (autoTemplates, default on)', () => {
784
854
  expect(roundTripped).not.toContain('{[quote');
785
855
  });
786
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
+ });
@@ -44,6 +44,14 @@ describe('markdownToDoc media annotations', () => {
44
44
  });
45
45
  });
46
46
 
47
+ it('accepts startTime as an alias of startAt (startAt wins when both given)', () => {
48
+ const alias = toDoc('# B {duration=20}\n\n{[audio src=a.mp3 startTime=5]}\n');
49
+ expect(alias.blocks[0].media![0].startAt).toBe(5);
50
+
51
+ const both = toDoc('# B {duration=20}\n\n{[audio src=a.mp3 startAt=3 startTime=5]}\n');
52
+ expect(both.blocks[0].media![0].startAt).toBe(3);
53
+ });
54
+
47
55
  it('media annotation text does not inflate block duration', () => {
48
56
  // No explicit duration; body is only the annotation → default (5s), not reading-time of the annotation text.
49
57
  const doc = toDoc('# B\n\n{[audio src=a.mp3]}\n');
@@ -62,3 +70,22 @@ describe('markdownToDoc media annotations', () => {
62
70
  expect(block.absoluteStart).toBe(35); // block Two at 30 + startAt 5
63
71
  });
64
72
  });
73
+
74
+ describe('media annotations vs standalone template blocks', () => {
75
+ it('extracts media annotations as clips, not standalone blocks (media names win)', () => {
76
+ const doc = toDoc('# One\n\nIntro.\n\n{[audio src=narration.mp3]}\n\n{[video src=clip.mp4]}\n');
77
+ const block = doc.blocks[0];
78
+ expect(block.media?.map((m) => m.kind)).toEqual(['audio', 'video']);
79
+ // No sibling standalone blocks were produced for the media names.
80
+ expect(doc.blocks).toHaveLength(1);
81
+ expect(doc.blocks.some((b) => b.standaloneAnnotation)).toBe(false);
82
+ });
83
+
84
+ it('mixes a media clip and a standalone template block in one section', () => {
85
+ const doc = toDoc('# One\n\n{[audio src=n.mp3]}\n\n{[quote]}\n\nquoted\n');
86
+ expect(doc.blocks[0].media?.map((m) => m.kind)).toEqual(['audio']);
87
+ const q = doc.blocks[1];
88
+ expect(q.template).toBe('quote');
89
+ expect(q.standaloneAnnotation).toBe(true);
90
+ });
91
+ });
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { parseMarkdown } from '../markdown/parse.js';
3
3
  import { markdownToDoc } from '../doc/markdownToDoc.js';
4
- import { parseYamlSubset } from '../doc/structuredData.js';
4
+ import { parseYamlSubset, replaceDataFence } from '../doc/structuredData.js';
5
+ import type { MarkdownCodeBlock } from '../markdown/types.js';
5
6
 
6
7
  function toDoc(md: string) {
7
8
  return markdownToDoc(parseMarkdown(md));
@@ -41,8 +42,21 @@ describe('parseYamlSubset', () => {
41
42
  expect(parseYamlSubset('# comment\n\nkey: value')).toEqual({ key: 'value' });
42
43
  });
43
44
 
44
- it('rejects nested mappings with a line-anchored error', () => {
45
- expect(() => parseYamlSubset('outer:\n inner: 1')).toThrow(/line 2/);
45
+ it('parses one level of nested mapping', () => {
46
+ expect(parseYamlSubset('center:\n lat: 47.6\n lng: -122.3\nzoom: 12')).toEqual({
47
+ center: { lat: 47.6, lng: -122.3 },
48
+ zoom: 12,
49
+ });
50
+ });
51
+
52
+ it('rejects nesting deeper than one level with a line-anchored error', () => {
53
+ expect(() => parseYamlSubset('a:\n b:\n c: 1')).toThrow(/line 2/);
54
+ expect(() => parseYamlSubset('a:\n b: 1\n c: 2')).toThrow(/line 3/);
55
+ });
56
+
57
+ it('rejects mixing list items and mapping keys under one key', () => {
58
+ expect(() => parseYamlSubset('a:\n - x\n b: 1')).toThrow(/mix/);
59
+ expect(() => parseYamlSubset('a:\n b: 1\n - x')).toThrow(/mix/);
46
60
  });
47
61
  });
48
62
 
@@ -93,6 +107,62 @@ describe('data fences → block.templateData', () => {
93
107
  });
94
108
  });
95
109
 
110
+ describe('replaceDataFence', () => {
111
+ it('replaces the first data fence content, keeping lang and meta', () => {
112
+ const doc = toDoc(
113
+ '## Numbers {[dataTable]}\n\nIntro.\n\n```json data\n{ "headers": ["Q"] }\n```',
114
+ );
115
+ const contents = doc.blocks[0].contents!;
116
+
117
+ const next = replaceDataFence(contents, { headers: ['Q', 'Revenue'] });
118
+
119
+ const fence = next.find((n): n is MarkdownCodeBlock => n.type === 'code')!;
120
+ expect(fence.lang).toBe('json');
121
+ expect(fence.meta).toBe('data');
122
+ expect(JSON.parse(fence.value)).toEqual({ headers: ['Q', 'Revenue'] });
123
+ // Pretty-printed, not single-line.
124
+ expect(fence.value).toContain('\n');
125
+ // Same length, other nodes untouched.
126
+ expect(next).toHaveLength(contents.length);
127
+ expect(next[0]).toBe(contents[0]);
128
+ });
129
+
130
+ it('does not mutate the input array or the original fence node', () => {
131
+ const doc = toDoc('## Numbers {[dataTable]}\n\n```json data\n{ "a": 1 }\n```');
132
+ const contents = doc.blocks[0].contents!;
133
+ const originalFence = contents.find((n): n is MarkdownCodeBlock => n.type === 'code')!;
134
+
135
+ replaceDataFence(contents, { a: 2 });
136
+
137
+ expect(originalFence.value).toBe('{ "a": 1 }');
138
+ });
139
+
140
+ it('appends a new ```json data fence when none exists', () => {
141
+ const doc = toDoc('## Numbers {[dataTable]}\n\nJust text.');
142
+ const contents = doc.blocks[0].contents!;
143
+
144
+ const next = replaceDataFence(contents, { rows: [['Q1', '1.2M']] });
145
+
146
+ expect(next).toHaveLength(contents.length + 1);
147
+ const fence = next[next.length - 1] as MarkdownCodeBlock;
148
+ expect(fence).toMatchObject({ type: 'code', lang: 'json', meta: 'data' });
149
+ expect(JSON.parse(fence.value)).toEqual({ rows: [['Q1', '1.2M']] });
150
+ });
151
+
152
+ it('treats plain code fences (no data marker) as non-fences and appends', () => {
153
+ const doc = toDoc('## Code {[sectionHeader]}\n\n```json\n{ "just": "a sample" }\n```');
154
+ const contents = doc.blocks[0].contents!;
155
+
156
+ const next = replaceDataFence(contents, { a: 1 });
157
+
158
+ // The sample fence is untouched; a data fence is appended after it.
159
+ const sample = next.find((n): n is MarkdownCodeBlock => n.type === 'code' && !n.meta)!;
160
+ expect(sample.value).toBe('{ "just": "a sample" }');
161
+ expect(next).toHaveLength(contents.length + 1);
162
+ expect((next[next.length - 1] as MarkdownCodeBlock).meta).toBe('data');
163
+ });
164
+ });
165
+
96
166
  describe('dataTable eats GFM tables', () => {
97
167
  const table = ['| Name | Age |', '| --- | ---: |', '| Alice | 30 |', '| Bob | 25 |'].join('\n');
98
168