@bendyline/squisq-formats 2.0.1 → 2.2.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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +1 -1
  4. package/dist/{chunk-CRAVSMPZ.js → chunk-26ISNJ7Y.js} +356 -114
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-HTW2M27H.js → chunk-3NKXBZSR.js} +193 -42
  7. package/dist/{chunk-U32AG3G3.js → chunk-4V3KCHAP.js} +3 -4
  8. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  9. package/dist/{chunk-QRVN6A6E.js → chunk-6S6GU3ZG.js} +5 -6
  10. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  11. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  12. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  13. package/dist/{chunk-XKUMNGBW.js → chunk-EJTNGKEA.js} +5 -8
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  16. package/dist/{chunk-ABVI556T.js → chunk-IPN56VLW.js} +83 -58
  17. package/dist/{chunk-LXYLOOST.js → chunk-JE6LSIHE.js} +83 -22
  18. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  19. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  20. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  21. package/dist/chunk-OGS5VCGJ.js +446 -0
  22. package/dist/{chunk-GVS2XXV6.js → chunk-PJXJI2LY.js} +449 -57
  23. package/dist/{chunk-2KPARF2P.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-VSYHZECT.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-WC7WULGV.js → chunk-X2DEAXNK.js} +62 -2
  27. package/dist/container/index.js +1 -2
  28. package/dist/csv/index.d.ts +27 -2
  29. package/dist/csv/index.js +1 -2
  30. package/dist/docx/index.d.ts +5 -1
  31. package/dist/docx/index.js +9 -10
  32. package/dist/epub/index.d.ts +2 -0
  33. package/dist/epub/index.js +5 -6
  34. package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
  35. package/dist/extract-MN7LA3NL.js +13 -0
  36. package/dist/html/index.d.ts +11 -4
  37. package/dist/html/index.js +3 -4
  38. package/dist/images-ESPQKVTW.js +6 -0
  39. package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
  40. package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
  41. package/dist/index.d.ts +7 -7
  42. package/dist/index.js +28 -26
  43. package/dist/infer/index.d.ts +3 -3
  44. package/dist/infer/index.js +7 -9
  45. package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
  46. package/dist/layouts-DRWZGSPD.js +10 -0
  47. package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
  48. package/dist/ooxml/index.d.ts +3 -3
  49. package/dist/ooxml/index.js +14 -13
  50. package/dist/pdf/index.d.ts +18 -0
  51. package/dist/pdf/index.js +2 -3
  52. package/dist/pptx/index.d.ts +4 -4
  53. package/dist/pptx/index.js +11 -13
  54. package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
  55. package/dist/registry/index.d.ts +21 -5
  56. package/dist/registry/index.js +9 -6
  57. package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
  58. package/dist/xlsx/index.d.ts +3 -3
  59. package/dist/xlsx/index.js +6 -7
  60. package/package.json +6 -3
  61. package/dist/chunk-2KPARF2P.js.map +0 -1
  62. package/dist/chunk-4VUWTSGM.js.map +0 -1
  63. package/dist/chunk-6M7Z25LA.js +0 -46
  64. package/dist/chunk-6M7Z25LA.js.map +0 -1
  65. package/dist/chunk-ABVI556T.js.map +0 -1
  66. package/dist/chunk-CRAVSMPZ.js.map +0 -1
  67. package/dist/chunk-FE6OJV6O.js.map +0 -1
  68. package/dist/chunk-GVS2XXV6.js.map +0 -1
  69. package/dist/chunk-HTW2M27H.js.map +0 -1
  70. package/dist/chunk-LXYLOOST.js.map +0 -1
  71. package/dist/chunk-MLX2BOJC.js.map +0 -1
  72. package/dist/chunk-NKAJPJ4G.js.map +0 -1
  73. package/dist/chunk-O3GVVND4.js.map +0 -1
  74. package/dist/chunk-ODL3SSPT.js.map +0 -1
  75. package/dist/chunk-PN52A5AA.js.map +0 -1
  76. package/dist/chunk-QRVN6A6E.js.map +0 -1
  77. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  78. package/dist/chunk-SSUPBUF5.js.map +0 -1
  79. package/dist/chunk-U32AG3G3.js.map +0 -1
  80. package/dist/chunk-U4MRIFKL.js.map +0 -1
  81. package/dist/chunk-VJJM2SSH.js +0 -275
  82. package/dist/chunk-VJJM2SSH.js.map +0 -1
  83. package/dist/chunk-VSYHZECT.js.map +0 -1
  84. package/dist/chunk-WC7WULGV.js.map +0 -1
  85. package/dist/chunk-XKUMNGBW.js.map +0 -1
  86. package/dist/chunk-YRT7GQ5Y.js +0 -28
  87. package/dist/chunk-YRT7GQ5Y.js.map +0 -1
  88. package/dist/container/index.js.map +0 -1
  89. package/dist/csv/index.js.map +0 -1
  90. package/dist/docx/index.js.map +0 -1
  91. package/dist/epub/index.js.map +0 -1
  92. package/dist/extract-H6RXJMHP.js +0 -15
  93. package/dist/extract-H6RXJMHP.js.map +0 -1
  94. package/dist/html/index.js.map +0 -1
  95. package/dist/images-7FBWPKE3.js +0 -7
  96. package/dist/images-7FBWPKE3.js.map +0 -1
  97. package/dist/index.js.map +0 -1
  98. package/dist/infer/index.js.map +0 -1
  99. package/dist/layouts-QVPK3ZCU.js +0 -12
  100. package/dist/layouts-QVPK3ZCU.js.map +0 -1
  101. package/dist/mapTheme-IR27S6IV.js.map +0 -1
  102. package/dist/ooxml/index.js.map +0 -1
  103. package/dist/pdf/index.js.map +0 -1
  104. package/dist/pptx/index.js.map +0 -1
  105. package/dist/registry/index.js.map +0 -1
  106. package/dist/xlsx/index.js.map +0 -1
  107. package/src/__tests__/container.test.ts +0 -230
  108. package/src/__tests__/convert.test.ts +0 -495
  109. package/src/__tests__/csvImport.test.ts +0 -84
  110. package/src/__tests__/docxExport.test.ts +0 -457
  111. package/src/__tests__/docxImport.test.ts +0 -410
  112. package/src/__tests__/epub.test.ts +0 -649
  113. package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
  114. package/src/__tests__/formatRegistry.test.ts +0 -174
  115. package/src/__tests__/html.test.ts +0 -435
  116. package/src/__tests__/htmlImport.test.ts +0 -57
  117. package/src/__tests__/inferTheme.test.ts +0 -135
  118. package/src/__tests__/lossyWarnings.test.ts +0 -146
  119. package/src/__tests__/ooxml.test.ts +0 -271
  120. package/src/__tests__/ooxmlCancellation.test.ts +0 -113
  121. package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
  122. package/src/__tests__/pdfExport.test.ts +0 -322
  123. package/src/__tests__/pdfImport.test.ts +0 -384
  124. package/src/__tests__/plainHtml.test.ts +0 -417
  125. package/src/__tests__/plainHtmlBundle.test.ts +0 -253
  126. package/src/__tests__/pptxExport.test.ts +0 -138
  127. package/src/__tests__/pptxImport.test.ts +0 -145
  128. package/src/__tests__/pptxInferFixtures.ts +0 -314
  129. package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
  130. package/src/__tests__/roundTrip.test.ts +0 -201
  131. package/src/__tests__/roundTripAssets.test.ts +0 -50
  132. package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
  133. package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
  134. package/src/__tests__/roundTripMatrix.test.ts +0 -142
  135. package/src/__tests__/sharedContainer.test.ts +0 -41
  136. package/src/__tests__/sharedImages.test.ts +0 -61
  137. package/src/__tests__/xlsxExport.test.ts +0 -164
  138. package/src/__tests__/xlsxImport.test.ts +0 -80
  139. package/src/__tests__/zipSafety.test.ts +0 -317
  140. package/src/container/index.ts +0 -94
  141. package/src/csv/index.ts +0 -188
  142. package/src/docx/export.ts +0 -1267
  143. package/src/docx/import.ts +0 -995
  144. package/src/docx/index.ts +0 -26
  145. package/src/docx/styles.ts +0 -145
  146. package/src/epub/export.ts +0 -968
  147. package/src/epub/index.ts +0 -20
  148. package/src/html/docsHtmlBundle.ts +0 -373
  149. package/src/html/htmlTemplate.ts +0 -385
  150. package/src/html/imageUtils.ts +0 -61
  151. package/src/html/import.ts +0 -297
  152. package/src/html/index.ts +0 -212
  153. package/src/html/plainHtml.ts +0 -790
  154. package/src/html/plainHtmlBundle.ts +0 -421
  155. package/src/index.ts +0 -109
  156. package/src/infer/extract.ts +0 -127
  157. package/src/infer/index.ts +0 -199
  158. package/src/infer/mapTheme.ts +0 -176
  159. package/src/infer/types.ts +0 -27
  160. package/src/ooxml/index.ts +0 -111
  161. package/src/ooxml/namespaces.ts +0 -196
  162. package/src/ooxml/readUtils.ts +0 -44
  163. package/src/ooxml/reader.ts +0 -318
  164. package/src/ooxml/themeReader.ts +0 -197
  165. package/src/ooxml/types.ts +0 -103
  166. package/src/ooxml/writer.ts +0 -339
  167. package/src/ooxml/xmlUtils.ts +0 -123
  168. package/src/pdf/export.ts +0 -1084
  169. package/src/pdf/import.ts +0 -1164
  170. package/src/pdf/index.ts +0 -29
  171. package/src/pdf/styles.ts +0 -180
  172. package/src/pptx/export.ts +0 -1184
  173. package/src/pptx/import.ts +0 -455
  174. package/src/pptx/index.ts +0 -52
  175. package/src/pptx/layouts.ts +0 -1222
  176. package/src/pptx/styles.ts +0 -96
  177. package/src/pptx/templates.ts +0 -187
  178. package/src/registry/convert.ts +0 -433
  179. package/src/registry/defaultFormats.ts +0 -413
  180. package/src/registry/errors.ts +0 -46
  181. package/src/registry/index.ts +0 -43
  182. package/src/registry/registry.ts +0 -48
  183. package/src/registry/types.ts +0 -170
  184. package/src/shared/boundedZipArchive.ts +0 -383
  185. package/src/shared/container.ts +0 -28
  186. package/src/shared/fidelity.ts +0 -130
  187. package/src/shared/images.ts +0 -44
  188. package/src/shared/inlineRuns.ts +0 -99
  189. package/src/shared/text.ts +0 -41
  190. package/src/shared/zipEntryCount.ts +0 -151
  191. package/src/shared/zipLimits.ts +0 -296
  192. package/src/shared/zipSafety.ts +0 -19
  193. package/src/xlsx/export.ts +0 -253
  194. package/src/xlsx/import.ts +0 -160
  195. package/src/xlsx/index.ts +0 -35
@@ -1,87 +0,0 @@
1
- /**
2
- * Export theme reconciliation.
3
- *
4
- * Every export format must honor the theme the document selects in its
5
- * frontmatter (`squisq-theme`) — including an inline custom theme stored in
6
- * `squisq-custom-themes` — without an explicit `themeId` option. This guards
7
- * the fix that made PDF/EPUB read the frontmatter theme like PPTX/DOCX/HTML,
8
- * and that custom themes resolve doc-scoped in exports (no global register).
9
- */
10
-
11
- import { describe, it, expect } from 'vitest';
12
- import JSZip from 'jszip';
13
- import { compileTheme } from '@bendyline/squisq/schemas';
14
- import { writeCustomThemesToFrontmatter } from '@bendyline/squisq/doc';
15
- import { parseMarkdown } from '@bendyline/squisq/markdown';
16
- import { markdownDocToPptx } from '../pptx/export';
17
- import { markdownDocToDocx } from '../docx/export';
18
- import { markdownDocToPdf } from '../pdf/export';
19
- import { markdownDocToEpub } from '../epub/export';
20
- import { markdownDocToPlainHtml } from '../html/plainHtml';
21
-
22
- const brand = compileTheme({
23
- id: 'custom-brand',
24
- name: 'Brand',
25
- seedColors: { primary: '#ff0088', background: '#123456', text: '#abcdef' },
26
- });
27
-
28
- const withTheme = parseMarkdown(`---
29
- title: Audit
30
- squisq-theme: custom-brand
31
- squisq-custom-themes: ${writeCustomThemesToFrontmatter([brand])}
32
- ---
33
-
34
- # Heading One
35
-
36
- Body with a [link](https://example.com).
37
- `);
38
-
39
- const noTheme = parseMarkdown('# Heading One\n\nBody.\n');
40
-
41
- /** Concatenate the text of every XML/CSS/HTML entry in a ZIP export. */
42
- async function zipText(buf: ArrayBuffer): Promise<string> {
43
- const zip = await JSZip.loadAsync(buf);
44
- let text = '';
45
- for (const name of Object.keys(zip.files)) {
46
- if (/\.(xml|css|html|xhtml|rels)$/i.test(name)) {
47
- text += await zip.files[name].async('string');
48
- }
49
- }
50
- return text.toLowerCase();
51
- }
52
-
53
- describe('export theme reconciliation (frontmatter-only custom theme)', () => {
54
- it('HTML applies the frontmatter custom theme colors', () => {
55
- const html = markdownDocToPlainHtml(withTheme, {}).toLowerCase();
56
- expect(html).toContain('123456'); // background
57
- expect(html).toContain('abcdef'); // text
58
- });
59
-
60
- it('PPTX applies the frontmatter custom theme colors', async () => {
61
- const text = await zipText(await markdownDocToPptx(withTheme, {}));
62
- expect(text).toContain('123456');
63
- });
64
-
65
- it('DOCX applies the frontmatter custom theme colors', async () => {
66
- const text = await zipText(await markdownDocToDocx(withTheme, {}));
67
- expect(text).toContain('123456');
68
- });
69
-
70
- it('EPUB applies the frontmatter custom theme colors (frontmatter fallback)', async () => {
71
- const text = await zipText(await markdownDocToEpub(withTheme, {}));
72
- expect(text).toContain('123456');
73
- });
74
-
75
- it('PDF themes from frontmatter — themed output differs from un-themed', async () => {
76
- const themed = new Uint8Array(await markdownDocToPdf(withTheme, {}));
77
- const plain = new Uint8Array(await markdownDocToPdf(noTheme, {}));
78
- const differs = themed.length !== plain.length || !themed.every((b, i) => b === plain[i]);
79
- expect(differs).toBe(true);
80
- });
81
-
82
- it('an explicit themeId option still overrides the frontmatter theme', async () => {
83
- const bare = await zipText(await markdownDocToPptx(withTheme, { themeId: 'standard' }));
84
- // The custom background must NOT appear when the caller forces 'standard'.
85
- expect(bare).not.toContain('123456');
86
- });
87
- });
@@ -1,174 +0,0 @@
1
- /**
2
- * Tests for the format registry: createRegistry / defaultRegistry, capability
3
- * consistency across every built-in, and the structured error codes.
4
- */
5
-
6
- import { describe, it, expect } from 'vitest';
7
- import { parseMarkdown } from '@bendyline/squisq/markdown';
8
- import { markdownToDoc } from '@bendyline/squisq/doc';
9
- import { MemoryContentContainer } from '@bendyline/squisq/storage';
10
- import {
11
- createRegistry,
12
- defaultRegistry,
13
- defaultFormats,
14
- ConversionError,
15
- BUILTIN_FORMAT_IDS,
16
- } from '../registry/index';
17
- import type { FormatDefinition, NormalizedInput } from '../registry/index';
18
-
19
- const SAMPLE_MD = `# Title
20
-
21
- Some intro text.
22
-
23
- | Name | Value |
24
- | --- | --- |
25
- | A | 1 |
26
- | B | 2 |
27
- `;
28
-
29
- async function makeInput(baseName = 'sample'): Promise<NormalizedInput> {
30
- const markdownDoc = parseMarkdown(SAMPLE_MD);
31
- const doc = markdownToDoc(markdownDoc);
32
- const container = new MemoryContentContainer();
33
- await container.writeDocument(SAMPLE_MD);
34
- return { doc, markdownDoc, container, baseName };
35
- }
36
-
37
- const stubDef = (id: string, extensions: string[]): FormatDefinition => ({
38
- id,
39
- label: id.toUpperCase(),
40
- mimeType: 'application/octet-stream',
41
- extensions,
42
- });
43
-
44
- // ── Registry mechanics ──────────────────────────────────────────────
45
-
46
- describe('createRegistry', () => {
47
- it('registers and gets definitions by id', () => {
48
- const reg = createRegistry();
49
- reg.register(stubDef('foo', ['.foo']));
50
- expect(reg.get('foo')?.id).toBe('foo');
51
- expect(reg.get('missing')).toBeUndefined();
52
- });
53
-
54
- it('resolves byExtension, normalizing leading dot + case', () => {
55
- const reg = createRegistry();
56
- reg.register(stubDef('foo', ['.foo', '.f']));
57
- expect(reg.byExtension('foo')?.id).toBe('foo');
58
- expect(reg.byExtension('.FOO')?.id).toBe('foo');
59
- expect(reg.byExtension('f')?.id).toBe('foo');
60
- expect(reg.byExtension('nope')).toBeUndefined();
61
- });
62
-
63
- it('is last-write-wins by id', () => {
64
- const reg = createRegistry();
65
- reg.register(stubDef('foo', ['.foo']));
66
- reg.register({ ...stubDef('foo', ['.foo']), label: 'SECOND' });
67
- expect(reg.get('foo')?.label).toBe('SECOND');
68
- expect(reg.list().filter((d) => d.id === 'foo')).toHaveLength(1);
69
- });
70
-
71
- it('lists all registered definitions', () => {
72
- const reg = createRegistry();
73
- reg.register(stubDef('a', ['.a']));
74
- reg.register(stubDef('b', ['.b']));
75
- expect(
76
- reg
77
- .list()
78
- .map((d) => d.id)
79
- .sort(),
80
- ).toEqual(['a', 'b']);
81
- });
82
- });
83
-
84
- describe('defaultRegistry', () => {
85
- it('registers all built-in formats', () => {
86
- const reg = defaultRegistry();
87
- for (const id of BUILTIN_FORMAT_IDS) {
88
- expect(reg.get(id), `built-in "${id}" should be registered`).toBeDefined();
89
- }
90
- expect(reg.list()).toHaveLength(BUILTIN_FORMAT_IDS.length);
91
- });
92
-
93
- it('defaultFormats matches BUILTIN_FORMAT_IDS exactly', () => {
94
- const ids = defaultFormats()
95
- .map((d) => d.id)
96
- .sort();
97
- expect(ids).toEqual([...BUILTIN_FORMAT_IDS].sort());
98
- });
99
- });
100
-
101
- // ── Capability consistency ──────────────────────────────────────────
102
-
103
- describe('built-in capability consistency', () => {
104
- const formats = defaultFormats();
105
-
106
- for (const def of formats) {
107
- if (!def.exportDoc) continue;
108
- it(`${def.id}: exportDoc produces bytes (or a tolerated not-implemented error)`, async () => {
109
- const input = await makeInput();
110
- // HTML formats need a player script; provide a trivial stub.
111
- const options = { resolvePlayerScript: async () => '/* stub */' };
112
- try {
113
- const result = await def.exportDoc!(input, options);
114
- // Use ArrayBuffer.isView rather than `instanceof Uint8Array`: under the
115
- // jsdom test env, bytes built from node's TextEncoder/Uint8Array live in
116
- // a different realm than the test file's Uint8Array global.
117
- expect(ArrayBuffer.isView(result.bytes)).toBe(true);
118
- expect(result.bytes.byteLength).toBeGreaterThan(0);
119
- expect(typeof result.mimeType).toBe('string');
120
- expect(Array.isArray(result.warnings)).toBe(true);
121
- } catch (err: unknown) {
122
- // xlsx export is being implemented concurrently and may still throw
123
- // 'not yet implemented'. Tolerate that specific case only.
124
- const message = err instanceof Error ? err.message : String(err);
125
- expect(def.id, `unexpected export failure for "${def.id}": ${message}`).toBe('xlsx');
126
- expect(message).toMatch(/not yet implemented/i);
127
- }
128
- });
129
- }
130
-
131
- it('csv export warns when the document has multiple tables', async () => {
132
- const multiTable = parseMarkdown(
133
- `| A | B |\n| - | - |\n| 1 | 2 |\n\n| C | D |\n| - | - |\n| 3 | 4 |\n`,
134
- );
135
- const container = new MemoryContentContainer();
136
- const input: NormalizedInput = {
137
- doc: markdownToDoc(multiTable),
138
- markdownDoc: multiTable,
139
- container,
140
- baseName: 'tables',
141
- };
142
- const csv = defaultFormats().find((d) => d.id === 'csv')!;
143
- const result = await csv.exportDoc!(input, {});
144
- expect(result.warnings.length).toBeGreaterThan(0);
145
- expect(result.warnings[0]).toMatch(/table/i);
146
- });
147
- });
148
-
149
- // ── Error codes ─────────────────────────────────────────────────────
150
-
151
- describe('error codes', () => {
152
- it('ConversionError carries a stable code + optional fields', () => {
153
- const err = new ConversionError('invalid-input', 'boom', {
154
- format: 'docx',
155
- hint: 'try again',
156
- cause: new Error('root'),
157
- });
158
- expect(err).toBeInstanceOf(Error);
159
- expect(err.name).toBe('ConversionError');
160
- expect(err.code).toBe('invalid-input');
161
- expect(err.format).toBe('docx');
162
- expect(err.hint).toBe('try again');
163
- expect((err as Error & { cause?: unknown }).cause).toBeInstanceOf(Error);
164
- });
165
-
166
- it('html export without a player script → missing-dependency', async () => {
167
- const input = await makeInput();
168
- const html = defaultFormats().find((d) => d.id === 'html')!;
169
- await expect(html.exportDoc!(input, {})).rejects.toMatchObject({
170
- name: 'ConversionError',
171
- code: 'missing-dependency',
172
- });
173
- });
174
- });
@@ -1,435 +0,0 @@
1
- /**
2
- * Tests for HTML export: docToHtml, docToHtmlZip, image utilities.
3
- *
4
- * Verifies that generated HTML contains the expected structure,
5
- * embedded script, and image handling. ZIP exports are also validated.
6
- */
7
-
8
- import { describe, it, expect } from 'vitest';
9
- import JSZip from 'jszip';
10
- import type { Doc, Block, ImageLayer } from '@bendyline/squisq/schemas';
11
- import { compileTheme, createThemeRegistry } from '@bendyline/squisq/schemas';
12
- import { docToHtml, docToHtmlZip, collectImagePaths } from '../html/index';
13
- import { inferMimeType, arrayBufferToBase64DataUrl, extractFilename } from '../html/imageUtils';
14
-
15
- // ============================================
16
- // Helpers
17
- // ============================================
18
-
19
- const MOCK_PLAYER_SCRIPT = 'var SquisqPlayer={mount:function(){}};';
20
-
21
- /** Helper to read a Blob as Uint8Array (works in jsdom where blob.arrayBuffer() may not exist) */
22
- async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
23
- return new Promise((resolve, reject) => {
24
- const reader = new FileReader();
25
- reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
26
- reader.onerror = () => reject(reader.error);
27
- reader.readAsArrayBuffer(blob);
28
- });
29
- }
30
-
31
- function makeDoc(overrides: Partial<Doc> = {}): Doc {
32
- return {
33
- articleId: 'test-doc',
34
- duration: 10,
35
- blocks: [],
36
- audio: {
37
- segments: [{ src: 'intro.mp3', name: 'intro', duration: 10, startTime: 0 }],
38
- },
39
- ...overrides,
40
- };
41
- }
42
-
43
- function makeImageBlock(imageSrc: string): Block {
44
- const layer: ImageLayer = {
45
- id: 'img-1',
46
- type: 'image',
47
- position: { x: 0, y: 0, width: '100%', height: '100%' },
48
- content: { src: imageSrc, alt: 'test image' },
49
- };
50
- return {
51
- id: 'block-1',
52
- startTime: 0,
53
- duration: 5,
54
- audioSegment: 0,
55
- layers: [layer],
56
- };
57
- }
58
-
59
- function makeImageBuffer(): ArrayBuffer {
60
- // Minimal 1x1 PNG
61
- const bytes = new Uint8Array([
62
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
63
- 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
64
- 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
65
- 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
66
- 0x44, 0xae, 0x42, 0x60, 0x82,
67
- ]);
68
- return bytes.buffer;
69
- }
70
-
71
- // ============================================
72
- // Image Utilities
73
- // ============================================
74
-
75
- describe('inferMimeType', () => {
76
- it('returns correct MIME types for common extensions', () => {
77
- expect(inferMimeType('photo.jpg')).toBe('image/jpeg');
78
- expect(inferMimeType('photo.jpeg')).toBe('image/jpeg');
79
- expect(inferMimeType('logo.png')).toBe('image/png');
80
- expect(inferMimeType('anim.gif')).toBe('image/gif');
81
- expect(inferMimeType('hero.webp')).toBe('image/webp');
82
- expect(inferMimeType('icon.svg')).toBe('image/svg+xml');
83
- expect(inferMimeType('track.mp3')).toBe('audio/mpeg');
84
- });
85
-
86
- it('returns octet-stream for unknown extensions', () => {
87
- expect(inferMimeType('data.xyz')).toBe('application/octet-stream');
88
- expect(inferMimeType('noext')).toBe('application/octet-stream');
89
- });
90
- });
91
-
92
- describe('arrayBufferToBase64DataUrl', () => {
93
- it('produces a valid data URI', () => {
94
- const buffer = new TextEncoder().encode('hello').buffer;
95
- const result = arrayBufferToBase64DataUrl(buffer, 'text/plain');
96
- expect(result).toMatch(/^data:text\/plain;base64,/);
97
- expect(result).toBe('data:text/plain;base64,aGVsbG8=');
98
- });
99
-
100
- it('encodes large buffers correctly across chunk boundaries', () => {
101
- const bytes = Uint8Array.from({ length: 100_003 }, (_, index) => index % 251);
102
- const result = arrayBufferToBase64DataUrl(bytes.buffer, 'application/octet-stream');
103
- const decoded = Uint8Array.from(atob(result.split(',')[1]), (character) =>
104
- character.charCodeAt(0),
105
- );
106
-
107
- expect(decoded).toEqual(bytes);
108
- });
109
- });
110
-
111
- describe('extractFilename', () => {
112
- it('extracts filename from path', () => {
113
- expect(extractFilename('images/hero.jpg')).toBe('hero.jpg');
114
- expect(extractFilename('hero.jpg')).toBe('hero.jpg');
115
- expect(extractFilename('a/b/c/deep.png')).toBe('deep.png');
116
- });
117
-
118
- it('strips query and hash', () => {
119
- expect(extractFilename('photo.jpg?v=2')).toBe('photo.jpg');
120
- expect(extractFilename('photo.jpg#anchor')).toBe('photo.jpg');
121
- });
122
- });
123
-
124
- // ============================================
125
- // Image Path Collection
126
- // ============================================
127
-
128
- describe('collectImagePaths', () => {
129
- it('collects image layer src from blocks', () => {
130
- const doc = makeDoc({ blocks: [makeImageBlock('hero.jpg')] });
131
- const paths = collectImagePaths(doc);
132
- expect(paths.has('hero.jpg')).toBe(true);
133
- });
134
-
135
- it('ignores absolute URLs', () => {
136
- const doc = makeDoc({ blocks: [makeImageBlock('https://example.com/photo.jpg')] });
137
- const paths = collectImagePaths(doc);
138
- expect(paths.size).toBe(0);
139
- });
140
-
141
- it('ignores data URIs', () => {
142
- const doc = makeDoc({ blocks: [makeImageBlock('data:image/png;base64,abc')] });
143
- const paths = collectImagePaths(doc);
144
- expect(paths.size).toBe(0);
145
- });
146
-
147
- it('collects startBlock heroSrc', () => {
148
- const doc = makeDoc({ startBlock: { heroSrc: 'cover.jpg', title: 'Test' } });
149
- const paths = collectImagePaths(doc);
150
- expect(paths.has('cover.jpg')).toBe(true);
151
- });
152
-
153
- it('collects markdown image refs from block contents', () => {
154
- const doc = makeDoc({
155
- blocks: [
156
- {
157
- id: 'b1',
158
- startTime: 0,
159
- duration: 5,
160
- audioSegment: 0,
161
- contents: [
162
- {
163
- type: 'paragraph',
164
- children: [{ type: 'image', url: 'body.jpg', alt: '' }],
165
- },
166
- ] as unknown as Block['contents'],
167
- },
168
- ],
169
- });
170
- const paths = collectImagePaths(doc);
171
- expect(paths.has('body.jpg')).toBe(true);
172
- });
173
-
174
- it('collects raw HTML <img> refs from block contents (WYSIWYG resize case)', () => {
175
- // When the WYSIWYG editor serializes a resized image, it emits a raw
176
- // `<img src width>` tag because markdown shorthand has no width syntax.
177
- // The parser produces an htmlBlock node with htmlChildren rather than
178
- // an image node — earlier this was invisible to the export pipeline.
179
- const doc = makeDoc({
180
- blocks: [
181
- {
182
- id: 'b1',
183
- startTime: 0,
184
- duration: 5,
185
- audioSegment: 0,
186
- contents: [
187
- {
188
- type: 'htmlBlock',
189
- rawHtml: '<img alt="resized" src="resized.jpg" width="194">',
190
- htmlChildren: [
191
- {
192
- type: 'htmlElement',
193
- tagName: 'img',
194
- attributes: { src: 'resized.jpg', alt: 'resized', width: '194' },
195
- children: [],
196
- selfClosing: true,
197
- },
198
- ],
199
- },
200
- ] as unknown as Block['contents'],
201
- },
202
- ],
203
- });
204
- const paths = collectImagePaths(doc);
205
- expect(paths.has('resized.jpg')).toBe(true);
206
- });
207
- });
208
-
209
- // ============================================
210
- // Single HTML Export
211
- // ============================================
212
-
213
- describe('docToHtml', () => {
214
- it('produces a complete HTML document', () => {
215
- const doc = makeDoc();
216
- const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
217
-
218
- expect(html).toContain('<!DOCTYPE html>');
219
- expect(html).toContain('<html');
220
- expect(html).toContain('</html>');
221
- expect(html).toContain('<meta charset="UTF-8">');
222
- expect(html).toContain('<div id="squisq-root">');
223
- });
224
-
225
- it('embeds the player script inline', () => {
226
- const doc = makeDoc();
227
- const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
228
-
229
- expect(html).toContain(MOCK_PLAYER_SCRIPT);
230
- });
231
-
232
- it('embeds the doc JSON', () => {
233
- const doc = makeDoc();
234
- const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
235
-
236
- expect(html).toContain('test-doc');
237
- });
238
-
239
- it('embeds an explicitly registered theme selected by the document', () => {
240
- const external = compileTheme({
241
- id: 'tenant-brand',
242
- name: 'External Tenant Brand',
243
- seedColors: { primary: '#6633cc' },
244
- });
245
- const html = docToHtml(makeDoc({ themeId: external.id }), {
246
- playerScript: MOCK_PLAYER_SCRIPT,
247
- themeRegistry: createThemeRegistry([external]),
248
- });
249
-
250
- expect(html).toContain('External Tenant Brand');
251
- expect(html).toContain('\\"themeId\\":\\"tenant-brand\\"');
252
- });
253
-
254
- it('keeps a document-scoped theme ahead of the explicit registry', () => {
255
- const inline = compileTheme({
256
- id: 'tenant-brand',
257
- name: 'Inline Tenant Brand',
258
- seedColors: { primary: '#112233' },
259
- });
260
- const external = compileTheme({
261
- id: 'tenant-brand',
262
- name: 'External Tenant Brand',
263
- seedColors: { primary: '#6633cc' },
264
- });
265
- const html = docToHtml(makeDoc({ themeId: inline.id, customThemes: [inline] }), {
266
- playerScript: MOCK_PLAYER_SCRIPT,
267
- themeRegistry: createThemeRegistry([external]),
268
- });
269
-
270
- expect(html).toContain('Inline Tenant Brand');
271
- expect(html).not.toContain('External Tenant Brand');
272
- });
273
-
274
- it('calls SquisqPlayer.mount', () => {
275
- const doc = makeDoc();
276
- const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
277
-
278
- expect(html).toContain('SquisqPlayer.mount');
279
- });
280
-
281
- it('sets the page title', () => {
282
- const doc = makeDoc();
283
- const html = docToHtml(doc, {
284
- playerScript: MOCK_PLAYER_SCRIPT,
285
- title: 'My Amazing Doc',
286
- });
287
-
288
- expect(html).toContain('<title>My Amazing Doc</title>');
289
- });
290
-
291
- it('uses slideshow mode by default', () => {
292
- const doc = makeDoc();
293
- const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
294
-
295
- expect(html).toContain('"slideshow"');
296
- });
297
-
298
- it('supports static mode', () => {
299
- const doc = makeDoc();
300
- const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT, mode: 'static' });
301
-
302
- expect(html).toContain('"static"');
303
- });
304
-
305
- it('inlines images as base64 data URIs', () => {
306
- const doc = makeDoc({ blocks: [makeImageBlock('hero.png')] });
307
- const images = new Map([['hero.png', makeImageBuffer()]]);
308
- const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT, images });
309
-
310
- expect(html).toContain('data:image/png;base64,');
311
- });
312
-
313
- it('escapes script-breaking content in title', () => {
314
- const doc = makeDoc();
315
- const html = docToHtml(doc, {
316
- playerScript: MOCK_PLAYER_SCRIPT,
317
- title: '<script>alert("xss")</script>',
318
- });
319
-
320
- // Title should be escaped
321
- expect(html).not.toContain('<script>alert');
322
- expect(html).toContain('&lt;script&gt;');
323
- });
324
- });
325
-
326
- // ============================================
327
- // ZIP Archive Export
328
- // ============================================
329
-
330
- describe('docToHtmlZip', () => {
331
- it('produces a valid ZIP blob', async () => {
332
- const doc = makeDoc();
333
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
334
-
335
- expect(blob).toBeInstanceOf(Blob);
336
- expect(blob.size).toBeGreaterThan(0);
337
- });
338
-
339
- it('contains index.html and squisq-player.js', async () => {
340
- const doc = makeDoc();
341
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
342
-
343
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
344
- expect(zip.file('index.html')).not.toBeNull();
345
- expect(zip.file('squisq-player.js')).not.toBeNull();
346
- });
347
-
348
- it('references player script via src attribute', async () => {
349
- const doc = makeDoc();
350
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
351
-
352
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
353
- const html = await zip.file('index.html')!.async('text');
354
- expect(html).toContain('src="squisq-player.js"');
355
- // Should NOT contain the inline script
356
- expect(html).not.toContain(MOCK_PLAYER_SCRIPT);
357
- });
358
-
359
- it('embeds a caller-owned theme selected by the document', async () => {
360
- const external = compileTheme({
361
- id: 'tenant-zip-brand',
362
- name: 'Tenant ZIP Brand',
363
- seedColors: { primary: '#224466' },
364
- });
365
- const blob = await docToHtmlZip(makeDoc({ themeId: external.id }), {
366
- playerScript: MOCK_PLAYER_SCRIPT,
367
- themeRegistry: createThemeRegistry([external]),
368
- });
369
-
370
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
371
- const html = await zip.file('index.html')!.async('text');
372
- expect(html).toContain('Tenant ZIP Brand');
373
- });
374
-
375
- it('preserves the original image path inside the zip', async () => {
376
- // Direct `<img src="folder/file.png">` references in the rendered HTML
377
- // resolve only if the zip mirrors the doc's path layout — flattening
378
- // to `images/<basename>` would 404 in the static renderer (which does
379
- // not rewrite paths through imagePathMap).
380
- const doc = makeDoc({ blocks: [makeImageBlock('mikehome_files/hero.png')] });
381
- const images = new Map([['mikehome_files/hero.png', makeImageBuffer()]]);
382
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT, images });
383
-
384
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
385
- expect(zip.file('mikehome_files/hero.png')).not.toBeNull();
386
- expect(zip.file('images/hero.png')).toBeNull();
387
- });
388
-
389
- it('strips leading slashes and rejects parent-traversal paths', async () => {
390
- const doc = makeDoc();
391
- const buffer = makeImageBuffer();
392
- const images = new Map([
393
- ['/leading/slash.png', buffer],
394
- ['../escape.png', buffer],
395
- ['nested/../sneaky.png', buffer],
396
- ['ok/path.png', buffer],
397
- ]);
398
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT, images });
399
-
400
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
401
- expect(zip.file('leading/slash.png')).not.toBeNull();
402
- expect(zip.file('ok/path.png')).not.toBeNull();
403
- expect(zip.file('../escape.png')).toBeNull();
404
- expect(zip.file('nested/../sneaky.png')).toBeNull();
405
- });
406
-
407
- it('includes audio in audio/ folder when provided', async () => {
408
- const audioData = new Uint8Array([0x49, 0x44, 0x33, 0x00]).buffer;
409
- const doc = makeDoc();
410
- const audio = new Map([['intro.mp3', audioData]]);
411
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT, audio });
412
-
413
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
414
- expect(zip.file('audio/intro.mp3')).not.toBeNull();
415
- });
416
-
417
- it('HTML references doc JSON inline', async () => {
418
- const doc = makeDoc();
419
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
420
-
421
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
422
- const html = await zip.file('index.html')!.async('text');
423
- expect(html).toContain('test-doc');
424
- expect(html).toContain('SquisqPlayer.mount');
425
- });
426
-
427
- it('player.js contains the provided script', async () => {
428
- const doc = makeDoc();
429
- const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
430
-
431
- const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
432
- const js = await zip.file('squisq-player.js')!.async('text');
433
- expect(js).toBe(MOCK_PLAYER_SCRIPT);
434
- });
435
- });