@bendyline/squisq-formats 1.2.3 → 1.3.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 (166) hide show
  1. package/dist/chunk-33YRFXZZ.js +1187 -0
  2. package/dist/chunk-33YRFXZZ.js.map +1 -0
  3. package/dist/{chunk-EUQQYBZZ.js → chunk-ERZ627GR.js} +4 -4
  4. package/dist/chunk-ERZ627GR.js.map +1 -0
  5. package/dist/{chunk-MEZF76JA.js → chunk-UDS45KUJ.js} +68 -25
  6. package/dist/chunk-UDS45KUJ.js.map +1 -0
  7. package/dist/{chunk-NGWHV77G.js → chunk-VN2KEOYB.js} +7 -6
  8. package/dist/chunk-VN2KEOYB.js.map +1 -0
  9. package/dist/container/index.d.ts +7 -5
  10. package/dist/container/index.js +39 -70
  11. package/dist/container/index.js.map +1 -1
  12. package/dist/docx/index.d.ts +128 -15
  13. package/dist/docx/index.js +17 -22
  14. package/dist/docx/index.js.map +1 -1
  15. package/dist/epub/index.d.ts +66 -11
  16. package/dist/epub/index.js +10 -18
  17. package/dist/epub/index.js.map +1 -1
  18. package/dist/html/index.d.ts +278 -9
  19. package/dist/html/index.js +26 -137
  20. package/dist/html/index.js.map +1 -1
  21. package/dist/index.d.ts +9 -38
  22. package/dist/index.js +62 -37
  23. package/dist/index.js.map +1 -1
  24. package/dist/ooxml/index.d.ts +353 -14
  25. package/dist/ooxml/index.js +116 -22
  26. package/dist/ooxml/index.js.map +1 -1
  27. package/dist/pdf/index.d.ts +143 -17
  28. package/dist/pdf/index.js +16 -25
  29. package/dist/pdf/index.js.map +1 -1
  30. package/dist/pptx/index.d.ts +83 -8
  31. package/dist/pptx/index.js +14 -34
  32. package/dist/pptx/index.js.map +1 -1
  33. package/dist/xlsx/index.d.ts +12 -9
  34. package/dist/xlsx/index.js +12 -44
  35. package/dist/xlsx/index.js.map +1 -1
  36. package/package.json +3 -2
  37. package/src/__tests__/html.test.ts +82 -4
  38. package/src/__tests__/pdfExport.test.ts +1 -1
  39. package/src/__tests__/plainHtml.test.ts +372 -0
  40. package/src/__tests__/plainHtmlBundle.test.ts +235 -0
  41. package/src/docx/export.ts +163 -22
  42. package/src/epub/export.ts +3 -3
  43. package/src/html/docsHtmlBundle.ts +369 -0
  44. package/src/html/htmlTemplate.ts +40 -0
  45. package/src/html/index.ts +32 -3
  46. package/src/html/plainHtml.ts +736 -0
  47. package/src/html/plainHtmlBundle.ts +419 -0
  48. package/src/pptx/export.ts +10 -9
  49. package/dist/__tests__/container.test.d.ts +0 -8
  50. package/dist/__tests__/container.test.d.ts.map +0 -1
  51. package/dist/__tests__/container.test.js +0 -147
  52. package/dist/__tests__/container.test.js.map +0 -1
  53. package/dist/__tests__/docxExport.test.d.ts +0 -8
  54. package/dist/__tests__/docxExport.test.d.ts.map +0 -1
  55. package/dist/__tests__/docxExport.test.js +0 -383
  56. package/dist/__tests__/docxExport.test.js.map +0 -1
  57. package/dist/__tests__/docxImport.test.d.ts +0 -8
  58. package/dist/__tests__/docxImport.test.d.ts.map +0 -1
  59. package/dist/__tests__/docxImport.test.js +0 -309
  60. package/dist/__tests__/docxImport.test.js.map +0 -1
  61. package/dist/__tests__/epub.test.d.ts +0 -8
  62. package/dist/__tests__/epub.test.d.ts.map +0 -1
  63. package/dist/__tests__/epub.test.js +0 -472
  64. package/dist/__tests__/epub.test.js.map +0 -1
  65. package/dist/__tests__/html.test.d.ts +0 -8
  66. package/dist/__tests__/html.test.d.ts.map +0 -1
  67. package/dist/__tests__/html.test.js +0 -243
  68. package/dist/__tests__/html.test.js.map +0 -1
  69. package/dist/__tests__/ooxml.test.d.ts +0 -5
  70. package/dist/__tests__/ooxml.test.d.ts.map +0 -1
  71. package/dist/__tests__/ooxml.test.js +0 -152
  72. package/dist/__tests__/ooxml.test.js.map +0 -1
  73. package/dist/__tests__/pdfExport.test.d.ts +0 -8
  74. package/dist/__tests__/pdfExport.test.d.ts.map +0 -1
  75. package/dist/__tests__/pdfExport.test.js +0 -275
  76. package/dist/__tests__/pdfExport.test.js.map +0 -1
  77. package/dist/__tests__/pdfImport.test.d.ts +0 -11
  78. package/dist/__tests__/pdfImport.test.d.ts.map +0 -1
  79. package/dist/__tests__/pdfImport.test.js +0 -254
  80. package/dist/__tests__/pdfImport.test.js.map +0 -1
  81. package/dist/__tests__/roundTrip.test.d.ts +0 -7
  82. package/dist/__tests__/roundTrip.test.d.ts.map +0 -1
  83. package/dist/__tests__/roundTrip.test.js +0 -173
  84. package/dist/__tests__/roundTrip.test.js.map +0 -1
  85. package/dist/chunk-EUQQYBZZ.js.map +0 -1
  86. package/dist/chunk-MEZF76JA.js.map +0 -1
  87. package/dist/chunk-NGWHV77G.js.map +0 -1
  88. package/dist/chunk-UM5V2XZG.js +0 -242
  89. package/dist/chunk-UM5V2XZG.js.map +0 -1
  90. package/dist/container/index.d.ts.map +0 -1
  91. package/dist/docx/export.d.ts +0 -69
  92. package/dist/docx/export.d.ts.map +0 -1
  93. package/dist/docx/export.js +0 -854
  94. package/dist/docx/export.js.map +0 -1
  95. package/dist/docx/import.d.ts +0 -65
  96. package/dist/docx/import.d.ts.map +0 -1
  97. package/dist/docx/import.js +0 -779
  98. package/dist/docx/import.js.map +0 -1
  99. package/dist/docx/index.d.ts.map +0 -1
  100. package/dist/docx/styles.d.ts +0 -68
  101. package/dist/docx/styles.d.ts.map +0 -1
  102. package/dist/docx/styles.js +0 -125
  103. package/dist/docx/styles.js.map +0 -1
  104. package/dist/epub/export.d.ts +0 -72
  105. package/dist/epub/export.d.ts.map +0 -1
  106. package/dist/epub/export.js +0 -698
  107. package/dist/epub/export.js.map +0 -1
  108. package/dist/epub/index.d.ts.map +0 -1
  109. package/dist/html/htmlTemplate.d.ts +0 -70
  110. package/dist/html/htmlTemplate.d.ts.map +0 -1
  111. package/dist/html/htmlTemplate.js +0 -239
  112. package/dist/html/htmlTemplate.js.map +0 -1
  113. package/dist/html/imageUtils.d.ts +0 -29
  114. package/dist/html/imageUtils.d.ts.map +0 -1
  115. package/dist/html/imageUtils.js +0 -63
  116. package/dist/html/imageUtils.js.map +0 -1
  117. package/dist/html/index.d.ts.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/ooxml/index.d.ts.map +0 -1
  120. package/dist/ooxml/namespaces.d.ts +0 -77
  121. package/dist/ooxml/namespaces.d.ts.map +0 -1
  122. package/dist/ooxml/namespaces.js +0 -104
  123. package/dist/ooxml/namespaces.js.map +0 -1
  124. package/dist/ooxml/reader.d.ts +0 -52
  125. package/dist/ooxml/reader.d.ts.map +0 -1
  126. package/dist/ooxml/reader.js +0 -190
  127. package/dist/ooxml/reader.js.map +0 -1
  128. package/dist/ooxml/types.d.ts +0 -82
  129. package/dist/ooxml/types.d.ts.map +0 -1
  130. package/dist/ooxml/types.js +0 -9
  131. package/dist/ooxml/types.js.map +0 -1
  132. package/dist/ooxml/writer.d.ts +0 -71
  133. package/dist/ooxml/writer.d.ts.map +0 -1
  134. package/dist/ooxml/writer.js +0 -200
  135. package/dist/ooxml/writer.js.map +0 -1
  136. package/dist/ooxml/xmlUtils.d.ts +0 -76
  137. package/dist/ooxml/xmlUtils.d.ts.map +0 -1
  138. package/dist/ooxml/xmlUtils.js +0 -108
  139. package/dist/ooxml/xmlUtils.js.map +0 -1
  140. package/dist/pdf/export.d.ts +0 -56
  141. package/dist/pdf/export.d.ts.map +0 -1
  142. package/dist/pdf/export.js +0 -768
  143. package/dist/pdf/export.js.map +0 -1
  144. package/dist/pdf/import.d.ts +0 -94
  145. package/dist/pdf/import.d.ts.map +0 -1
  146. package/dist/pdf/import.js +0 -796
  147. package/dist/pdf/import.js.map +0 -1
  148. package/dist/pdf/index.d.ts.map +0 -1
  149. package/dist/pdf/styles.d.ts +0 -142
  150. package/dist/pdf/styles.d.ts.map +0 -1
  151. package/dist/pdf/styles.js +0 -145
  152. package/dist/pdf/styles.js.map +0 -1
  153. package/dist/pptx/export.d.ts +0 -75
  154. package/dist/pptx/export.d.ts.map +0 -1
  155. package/dist/pptx/export.js +0 -722
  156. package/dist/pptx/export.js.map +0 -1
  157. package/dist/pptx/index.d.ts.map +0 -1
  158. package/dist/pptx/styles.d.ts +0 -54
  159. package/dist/pptx/styles.d.ts.map +0 -1
  160. package/dist/pptx/styles.js +0 -73
  161. package/dist/pptx/styles.js.map +0 -1
  162. package/dist/pptx/templates.d.ts +0 -24
  163. package/dist/pptx/templates.d.ts.map +0 -1
  164. package/dist/pptx/templates.js +0 -158
  165. package/dist/pptx/templates.js.map +0 -1
  166. package/dist/xlsx/index.d.ts.map +0 -1
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Tests for markdownDocToPlainHtml — the player-free, semantic HTML export.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
7
+ import { resolveTheme } from '@bendyline/squisq/schemas';
8
+ import { markdownDocToPlainHtml } from '../html/plainHtml';
9
+
10
+ function render(md: string, options?: Parameters<typeof markdownDocToPlainHtml>[1]): string {
11
+ return markdownDocToPlainHtml(parseMarkdown(md), options);
12
+ }
13
+
14
+ describe('markdownDocToPlainHtml', () => {
15
+ it('wraps output in a complete HTML document', () => {
16
+ const html = render('# Hello\n\nWorld');
17
+ expect(html.startsWith('<!DOCTYPE html>')).toBe(true);
18
+ expect(html).toContain('<html lang="en">');
19
+ expect(html).toContain('<head>');
20
+ expect(html).toContain('<body>');
21
+ expect(html.trim().endsWith('</html>')).toBe(true);
22
+ });
23
+
24
+ it('uses the title option and escapes it', () => {
25
+ const html = render('# x', { title: 'A <script> & more' });
26
+ expect(html).toContain('<title>A &lt;script&gt; &amp; more</title>');
27
+ });
28
+
29
+ it('renders headings at the correct depth', () => {
30
+ const html = render('# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six');
31
+ expect(html).toContain('<h1>One</h1>');
32
+ expect(html).toContain('<h2>Two</h2>');
33
+ expect(html).toContain('<h3>Three</h3>');
34
+ expect(html).toContain('<h4>Four</h4>');
35
+ expect(html).toContain('<h5>Five</h5>');
36
+ expect(html).toContain('<h6>Six</h6>');
37
+ });
38
+
39
+ it('renders paragraphs, emphasis, strong, and links', () => {
40
+ const html = render('A *b* **c** [link](https://example.com).');
41
+ expect(html).toContain(
42
+ '<p>A <em>b</em> <strong>c</strong> <a href="https://example.com">link</a>.</p>',
43
+ );
44
+ });
45
+
46
+ it('renders ordered and unordered lists', () => {
47
+ const html = render('- one\n- two\n\n1. first\n2. second');
48
+ expect(html).toContain('<ul>');
49
+ expect(html).toContain('<li>');
50
+ expect(html).toContain('<ol>');
51
+ });
52
+
53
+ it('emits start attribute on ordered lists with non-default start', () => {
54
+ const html = render('3. third\n4. fourth');
55
+ expect(html).toContain('<ol start="3">');
56
+ });
57
+
58
+ it('renders fenced code blocks with a language class', () => {
59
+ const html = render('```ts\nconst x = 1;\n```');
60
+ expect(html).toContain('<pre><code class="language-ts">const x = 1;</code></pre>');
61
+ });
62
+
63
+ it('escapes HTML characters inside code blocks', () => {
64
+ const html = render('```\n<script>alert(1)</script>\n```');
65
+ expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
66
+ });
67
+
68
+ it('renders blockquotes and thematic breaks', () => {
69
+ const html = render('> quoted\n\n---\n\nafter');
70
+ expect(html).toContain('<blockquote>');
71
+ expect(html).toContain('<hr />');
72
+ });
73
+
74
+ it('renders GFM tables', () => {
75
+ const html = render('| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |');
76
+ expect(html).toContain('<table>');
77
+ expect(html).toContain('<thead>');
78
+ expect(html).toContain('<th>H1</th>');
79
+ expect(html).toContain('<tbody>');
80
+ expect(html).toContain('<td>a</td>');
81
+ });
82
+
83
+ it('renders markdown images with src and alt', () => {
84
+ const html = render('![cat](cat.jpg)');
85
+ expect(html).toContain('<img src="cat.jpg" alt="cat" />');
86
+ });
87
+
88
+ it('substitutes image URLs through the images map', () => {
89
+ const html = render('![cat](cat.jpg)', {
90
+ images: new Map([['cat.jpg', 'blob:http://x/abcd']]),
91
+ });
92
+ expect(html).toContain('src="blob:http://x/abcd"');
93
+ expect(html).not.toContain('src="cat.jpg"');
94
+ });
95
+
96
+ it('passes URLs not in the images map through unchanged', () => {
97
+ const html = render('![ext](https://example.com/x.jpg)', {
98
+ images: new Map([['cat.jpg', 'unused']]),
99
+ });
100
+ expect(html).toContain('src="https://example.com/x.jpg"');
101
+ });
102
+
103
+ it('rewrites raw HTML <img src> through the images map (resize round-trip)', () => {
104
+ // The WYSIWYG editor serializes resized images as raw HTML because
105
+ // markdown shorthand has no width syntax. The renderer must walk
106
+ // the parsed htmlChildren so those images preview correctly.
107
+ const html = render('<img alt="resized" src="resized.png" width="194">', {
108
+ images: new Map([['resized.png', 'blob:http://x/resized']]),
109
+ });
110
+ expect(html).toContain('src="blob:http://x/resized"');
111
+ expect(html).toContain('width="194"');
112
+ expect(html).not.toContain('src="resized.png"');
113
+ });
114
+
115
+ it('escapes quotes in href and src attributes', () => {
116
+ const html = render('![a](a"b.jpg)\n\n[c](d"e)');
117
+ expect(html).toContain('&quot;');
118
+ expect(html).not.toContain('src="a"b.jpg"');
119
+ });
120
+
121
+ describe('feature template annotations', () => {
122
+ it('renders {[leftFeature]} headings as a two-column section with image left', () => {
123
+ const md = [
124
+ '# Mike Ammerlaan {[leftFeature]}',
125
+ '',
126
+ '![mike](mike.jpg)',
127
+ '',
128
+ 'Builder of platforms.',
129
+ '',
130
+ '## Other section',
131
+ '',
132
+ 'Unrelated.',
133
+ ].join('\n');
134
+ const html = render(md);
135
+ expect(html).toContain('<section class="squisq-feature squisq-feature--left">');
136
+ expect(html).toContain('<div class="squisq-feature__media"><img src="mike.jpg"');
137
+ expect(html).toContain('<h1>Mike Ammerlaan</h1>');
138
+ expect(html).toContain('<p>Builder of platforms.</p>');
139
+ // Closing section before the next heading
140
+ expect(html).toContain('</section>');
141
+ // Next heading lives OUTSIDE the section
142
+ const sectionClose = html.indexOf('</section>');
143
+ const otherHeading = html.indexOf('<h2>Other section');
144
+ expect(sectionClose).toBeLessThan(otherHeading);
145
+ });
146
+
147
+ it('renders {[rightFeature]} with image on the right (row-reverse class)', () => {
148
+ const html = render('## Projects {[rightFeature]}\n\n![p](p.png)\n\nList of stuff.');
149
+ expect(html).toContain('squisq-feature--right');
150
+ expect(html).toContain('<img src="p.png"');
151
+ });
152
+
153
+ it('keeps the image out of the body column to avoid double-rendering', () => {
154
+ const html = render('# Hello {[leftFeature]}\n\n![h](h.jpg)\n\nText body.');
155
+ // Exactly one <img>
156
+ expect(html.match(/<img /g)?.length).toBe(1);
157
+ });
158
+
159
+ it('still emits feature CSS when no feature blocks are present', () => {
160
+ // The CSS is part of the doc shell so themes pick up the rules
161
+ // when authored content adds a feature later; presence is fine.
162
+ const html = render('# Plain doc');
163
+ expect(html).toContain('.squisq-feature {');
164
+ });
165
+
166
+ it('handles raw HTML <img> as the feature image (resized round-trip)', () => {
167
+ const md = [
168
+ '# Mike {[rightFeature]}',
169
+ '',
170
+ '<img alt="m" src="m.jpg" width="194">',
171
+ '',
172
+ 'Body.',
173
+ ].join('\n');
174
+ const html = render(md);
175
+ expect(html).toContain('squisq-feature--right');
176
+ expect(html).toContain('<img src="m.jpg"');
177
+ });
178
+
179
+ it('forwards explicit width/height on the feature image so CSS can center it', () => {
180
+ const md =
181
+ '# Mike {[leftFeature]}\n\n<img alt="m" src="m.jpg" width="194" height="220">\n\nBody.';
182
+ const html = render(md);
183
+ // The media wrapper gets a "sized" modifier class that the CSS
184
+ // uses to switch from stretch-to-fill to center-with-padding.
185
+ expect(html).toContain('squisq-feature__media--sized');
186
+ // And the image element carries the original dimensions.
187
+ expect(html).toContain('width="194"');
188
+ expect(html).toContain('height="220"');
189
+ });
190
+
191
+ it('omits the sized modifier on the media div when the image has no explicit dims', () => {
192
+ // The `--sized` class lives in the embedded stylesheet regardless
193
+ // (so future feature blocks get styled correctly). What we care
194
+ // about here is that no `<div class="squisq-feature__media …--sized">`
195
+ // is emitted for an un-resized image.
196
+ const html = render('# Mike {[leftFeature]}\n\n![m](m.jpg)\n\nBody.');
197
+ expect(html).toMatch(/<div class="squisq-feature__media"><img /);
198
+ expect(html).not.toMatch(/<div class="squisq-feature__media[^"]*--sized/);
199
+ expect(html).not.toMatch(/<img [^>]*width=/);
200
+ });
201
+
202
+ it('substitutes feature image src through the images map', () => {
203
+ const md = '# Mike {[leftFeature]}\n\n![m](mike.jpg)\n\nText.';
204
+ const html = render(md, { images: new Map([['mike.jpg', 'data:image/png;base64,AAA']]) });
205
+ expect(html).toContain('src="data:image/png;base64,AAA"');
206
+ expect(html).not.toContain('src="mike.jpg"');
207
+ });
208
+
209
+ it('falls back gracefully when a feature heading has no image', () => {
210
+ const html = render('# Just Words {[leftFeature]}\n\nNo image here.');
211
+ expect(html).toContain('squisq-feature--left');
212
+ expect(html).toContain('squisq-feature__media--empty');
213
+ expect(html).toContain('<p>No image here.</p>');
214
+ });
215
+
216
+ it('section ends at the next heading of any depth', () => {
217
+ // A feature is intentionally short — first body chunk only. Any
218
+ // following heading (even a deeper H3) closes the section so
219
+ // headings don't end up inside the side-by-side layout where
220
+ // they'd render awkwardly.
221
+ const md = [
222
+ '## Mike {[leftFeature]}',
223
+ '',
224
+ '![m](m.jpg)',
225
+ '',
226
+ 'first paragraph',
227
+ '',
228
+ '### Sub',
229
+ '',
230
+ 'outside section',
231
+ ].join('\n');
232
+ const html = render(md);
233
+ const sectionClose = html.indexOf('</section>');
234
+ const subHeading = html.indexOf('<h3>Sub');
235
+ expect(sectionClose).toBeLessThan(subHeading);
236
+ });
237
+ });
238
+
239
+ describe('link rewriting via `links` option', () => {
240
+ it('rewrites <a href> through the supplied map', () => {
241
+ const html = render('See [my CV](resume.md) for details.', {
242
+ links: new Map([['resume.md', 'resume.html']]),
243
+ });
244
+ expect(html).toContain('href="resume.html"');
245
+ expect(html).not.toContain('href="resume.md"');
246
+ });
247
+
248
+ it('leaves URLs not in the map unchanged', () => {
249
+ const html = render('[external](https://example.com)', {
250
+ links: new Map([['resume.md', 'resume.html']]),
251
+ });
252
+ expect(html).toContain('href="https://example.com"');
253
+ });
254
+
255
+ it('preserves the original behavior when no map is supplied', () => {
256
+ const html = render('[my CV](resume.md)');
257
+ expect(html).toContain('href="resume.md"');
258
+ });
259
+ });
260
+
261
+ describe('inline FontAwesome icons', () => {
262
+ it('renders an inline icon as <i class="fa-brands fa-github">', () => {
263
+ const html = render('Built with {[github]}');
264
+ expect(html).toContain('<i class="fa-brands fa-github"');
265
+ expect(html).toContain('data-icon="github"');
266
+ });
267
+
268
+ it('emits the FontAwesome CDN <link> when any icon is present', () => {
269
+ const html = render('Hello {[github]} world');
270
+ expect(html).toContain('href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/');
271
+ expect(html).toContain('integrity="sha512-');
272
+ });
273
+
274
+ it('omits the FontAwesome <link> when no icons appear', () => {
275
+ const html = render('# Just text\n\nNo icons here.');
276
+ expect(html).not.toContain('font-awesome');
277
+ });
278
+
279
+ it('renders qualified tokens with the correct family', () => {
280
+ const html = render('{[fa-solid:user]}');
281
+ expect(html).toContain('<i class="fa-solid fa-user"');
282
+ expect(html).toContain('data-icon="fa-solid:user"');
283
+ });
284
+ });
285
+
286
+ describe('with a theme', () => {
287
+ it('embeds the theme colors as CSS custom properties', () => {
288
+ const theme = resolveTheme('warm-earth');
289
+ const html = render('# Hello', { theme });
290
+ expect(html).toContain(`--plain-bg: ${theme.colors.background};`);
291
+ expect(html).toContain(`--plain-text: ${theme.colors.text};`);
292
+ expect(html).toContain(`--plain-primary: ${theme.colors.primary};`);
293
+ });
294
+
295
+ it('resolves the theme typography to CSS family strings', () => {
296
+ const theme = resolveTheme('documentary');
297
+ const html = render('# Hello', { theme });
298
+ // documentary uses playfair (title) + source-serif (body)
299
+ expect(html).toContain('--plain-title-font: "Playfair Display"');
300
+ expect(html).toContain('--plain-body-font: "Source Serif 4"');
301
+ });
302
+
303
+ it('emits a Google Fonts <link> for themes that reference google-hosted faces', () => {
304
+ const theme = resolveTheme('documentary');
305
+ const html = render('# Hello', { theme });
306
+ expect(html).toContain('<link rel="preconnect" href="https://fonts.googleapis.com">');
307
+ expect(html).toContain('https://fonts.googleapis.com/css2?');
308
+ expect(html).toContain('family=Playfair+Display:wght@400;700');
309
+ expect(html).toContain('family=Source+Serif+4:wght@400;700');
310
+ expect(html).toContain('display=swap');
311
+ });
312
+
313
+ it('omits the Google Fonts <link> for themes that use only system stacks', () => {
314
+ const theme = resolveTheme('standard');
315
+ const html = render('# Hello', { theme });
316
+ expect(html).not.toContain('fonts.googleapis.com');
317
+ });
318
+
319
+ it('uses default styles when no theme is provided', () => {
320
+ const html = render('# Hello');
321
+ expect(html).not.toContain('--plain-bg');
322
+ expect(html).not.toContain('fonts.googleapis.com');
323
+ expect(html).toContain('system-ui');
324
+ });
325
+
326
+ it('auto-resolves theme from frontmatter when no explicit theme is passed', () => {
327
+ // Without a theme option, the doc's themeId in frontmatter should
328
+ // pick up the theme automatically — callers like docblocks that
329
+ // render plain HTML straight from a parsed doc get themed output
330
+ // without having to wire theme resolution themselves.
331
+ const html = render('---\ntitle: x\nthemeId: warm-earth\n---\n\n# Hello');
332
+ const warm = resolveTheme('warm-earth');
333
+ expect(html).toContain(`--plain-bg: ${warm.colors.background};`);
334
+ expect(html).toContain(`--plain-text: ${warm.colors.text};`);
335
+ });
336
+
337
+ it('explicit theme option still wins over frontmatter themeId', () => {
338
+ // The site's export dialog lets the user override the doc's theme
339
+ // at export time — the option must take precedence over what the
340
+ // frontmatter encodes.
341
+ const cinematic = resolveTheme('cinematic');
342
+ const html = render('---\nthemeId: warm-earth\n---\n\n# Hello', { theme: cinematic });
343
+ expect(html).toContain(`--plain-bg: ${cinematic.colors.background};`);
344
+ });
345
+ });
346
+
347
+ describe('image sizing', () => {
348
+ it('lets explicit width/height attributes survive without CSS overriding them', () => {
349
+ // The WYSIWYG editor writes width/height after a resize. The CSS
350
+ // must not force `width: auto` or `height: auto` on imgs that
351
+ // carry explicit dims — otherwise the user's sizing is silently
352
+ // discarded.
353
+ const html = render('<img src="x.png" width="300" height="200">');
354
+ // No rule forcing height:auto unconditionally — the constrained
355
+ // variant only kicks in when neither attribute is present.
356
+ expect(html).toContain('img:not([width]):not([height])');
357
+ expect(html).not.toMatch(/^\s*img\s*\{[^}]*height:\s*auto/m);
358
+ });
359
+
360
+ it('feature media keeps width/height attrs untouched on resized images', () => {
361
+ const md = '# Hi {[leftFeature]}\n\n<img src="m.jpg" width="300" height="200">\n\nBody.';
362
+ const html = render(md);
363
+ // The image still carries its dimensions in the markup…
364
+ expect(html).toContain('width="300"');
365
+ expect(html).toContain('height="200"');
366
+ // …and the sized-media CSS rule no longer forces width:auto /
367
+ // height:auto, which would have overridden them.
368
+ expect(html).not.toMatch(/\.squisq-feature__media--sized\s+img\s*\{[^}]*width:\s*auto/);
369
+ expect(html).not.toMatch(/\.squisq-feature__media--sized\s+img\s*\{[^}]*height:\s*auto/);
370
+ });
371
+ });
372
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tests for the recursive plain-HTML bundle export
3
+ * (`markdownDocsToPlainHtmlBundle`).
4
+ *
5
+ * Each test sets up an in-memory "container" — a `Map<path, string>`
6
+ * for markdown sources and a `Map<path, ArrayBuffer>` for binary
7
+ * assets — wires it up as the `readDocument` / `readBinary` callbacks,
8
+ * exports the bundle, and inspects the resulting JSZip.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import JSZip from 'jszip';
13
+ import { markdownDocsToPlainHtmlBundle } from '../html/plainHtmlBundle';
14
+
15
+ /** Helper to read a Blob as Uint8Array (works in jsdom). */
16
+ async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
17
+ return new Promise((resolve, reject) => {
18
+ const reader = new FileReader();
19
+ reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
20
+ reader.onerror = () => reject(reader.error);
21
+ reader.readAsArrayBuffer(blob);
22
+ });
23
+ }
24
+
25
+ function makeContainer(docs: Record<string, string>, binaries: Record<string, ArrayBuffer> = {}) {
26
+ return {
27
+ readDocument: async (p: string) => (p in docs ? docs[p] : null),
28
+ readBinary: async (p: string) => (p in binaries ? binaries[p] : null),
29
+ };
30
+ }
31
+
32
+ async function readZipPath(blob: Blob, path: string): Promise<string | null> {
33
+ const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
34
+ const file = zip.file(path);
35
+ return file ? file.async('text') : null;
36
+ }
37
+
38
+ async function listZipPaths(blob: Blob): Promise<string[]> {
39
+ const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
40
+ // JSZip emits implicit directory entries (paths ending in `/`) when
41
+ // any file lives in a subfolder. We're testing file content; the
42
+ // directory placeholders are noise.
43
+ return Object.keys(zip.files)
44
+ .filter((p) => !p.endsWith('/'))
45
+ .sort();
46
+ }
47
+
48
+ describe('markdownDocsToPlainHtmlBundle', () => {
49
+ it('exports a single doc with no links to a 1-file html', async () => {
50
+ const c = makeContainer({ 'home.md': '# Home\n\nJust a single page.' });
51
+ const blob = await markdownDocsToPlainHtmlBundle({
52
+ entryPath: 'home.md',
53
+ ...c,
54
+ });
55
+ const paths = await listZipPaths(blob);
56
+ expect(paths).toEqual(['home.html']);
57
+ const html = await readZipPath(blob, 'home.html');
58
+ expect(html).toContain('<h1>Home</h1>');
59
+ });
60
+
61
+ it('follows a linear chain home.md → resume.md and bundles both', async () => {
62
+ const c = makeContainer({
63
+ 'home.md': '# Home\n\nMy [resume](resume.md) is here.',
64
+ 'resume.md': '# Resume\n\nWork history.',
65
+ });
66
+ const blob = await markdownDocsToPlainHtmlBundle({
67
+ entryPath: 'home.md',
68
+ ...c,
69
+ });
70
+ const paths = await listZipPaths(blob);
71
+ expect(paths).toContain('home.html');
72
+ expect(paths).toContain('resume.html');
73
+ const home = await readZipPath(blob, 'home.html');
74
+ // The .md link is rewritten to .html
75
+ expect(home).toContain('href="resume.html"');
76
+ expect(home).not.toContain('href="resume.md"');
77
+ const resume = await readZipPath(blob, 'resume.html');
78
+ expect(resume).toContain('<h1>Resume</h1>');
79
+ });
80
+
81
+ it('cycle-detects so a.md ↔ b.md does not loop', async () => {
82
+ const c = makeContainer({
83
+ 'a.md': '# A\n\n[b](b.md)',
84
+ 'b.md': '# B\n\n[a](a.md)',
85
+ });
86
+ const blob = await markdownDocsToPlainHtmlBundle({
87
+ entryPath: 'a.md',
88
+ ...c,
89
+ });
90
+ const paths = await listZipPaths(blob);
91
+ expect(paths.sort()).toEqual(['a.html', 'b.html']);
92
+ });
93
+
94
+ it('does NOT follow ../parent.md (scope rule)', async () => {
95
+ const c = makeContainer({
96
+ 'subdir/intro.md': '# Intro\n\nLink to [parent](../outside.md).',
97
+ 'outside.md': '# Outside',
98
+ });
99
+ const blob = await markdownDocsToPlainHtmlBundle({
100
+ entryPath: 'subdir/intro.md',
101
+ ...c,
102
+ });
103
+ const paths = await listZipPaths(blob);
104
+ // `outside.md` lives ABOVE the entry doc's directory → out of scope.
105
+ expect(paths).toEqual(['subdir/intro.html']);
106
+ const intro = await readZipPath(blob, 'subdir/intro.html');
107
+ // The link is not rewritten to .html — it stays as authored.
108
+ expect(intro).toContain('href="../outside.md"');
109
+ });
110
+
111
+ it('follows links into subfolders (still in scope)', async () => {
112
+ const c = makeContainer({
113
+ 'home.md': '# Home\n\n[chapter 1](chapters/one.md)',
114
+ 'chapters/one.md': '# Chapter 1\n\nContent.',
115
+ });
116
+ const blob = await markdownDocsToPlainHtmlBundle({
117
+ entryPath: 'home.md',
118
+ ...c,
119
+ });
120
+ const paths = await listZipPaths(blob);
121
+ expect(paths).toContain('home.html');
122
+ expect(paths).toContain('chapters/one.html');
123
+ const home = await readZipPath(blob, 'home.html');
124
+ expect(home).toContain('href="chapters/one.html"');
125
+ });
126
+
127
+ it('preserves #anchor fragments when rewriting .md → .html', async () => {
128
+ const c = makeContainer({
129
+ 'home.md': '# Home\n\nJump to [exp](resume.md#experience).',
130
+ 'resume.md': '# Resume\n\n## Experience',
131
+ });
132
+ const blob = await markdownDocsToPlainHtmlBundle({
133
+ entryPath: 'home.md',
134
+ ...c,
135
+ });
136
+ const home = await readZipPath(blob, 'home.html');
137
+ expect(home).toContain('href="resume.html#experience"');
138
+ });
139
+
140
+ it('aborts with an error when a linked doc cannot be read', async () => {
141
+ const c = makeContainer({
142
+ 'home.md': '# Home\n\n[missing](does-not-exist.md)',
143
+ });
144
+ await expect(markdownDocsToPlainHtmlBundle({ entryPath: 'home.md', ...c })).rejects.toThrow(
145
+ /failed to read.*does-not-exist\.md/i,
146
+ );
147
+ });
148
+
149
+ it('emits image assets at their authored relative paths inside the zip', async () => {
150
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
151
+ const c = makeContainer(
152
+ { 'home.md': '# Home\n\n![hero](home_files/hero.png)' },
153
+ { 'home_files/hero.png': png },
154
+ );
155
+ const blob = await markdownDocsToPlainHtmlBundle({
156
+ entryPath: 'home.md',
157
+ ...c,
158
+ });
159
+ const paths = await listZipPaths(blob);
160
+ expect(paths).toContain('home_files/hero.png');
161
+ const home = await readZipPath(blob, 'home.html');
162
+ expect(home).toContain('src="home_files/hero.png"');
163
+ });
164
+
165
+ it('linked sub-folder docs reference parent assets via ../', async () => {
166
+ // The doc lives in subdir/, the image in home_files/, so the
167
+ // rewritten src should walk up one level.
168
+ const png = new Uint8Array([0x89]).buffer;
169
+ const c = makeContainer(
170
+ {
171
+ 'home.md': '# Home\n\n[deep](chapters/one.md)',
172
+ 'chapters/one.md': '# One\n\n![shared](../home_files/hero.png)',
173
+ },
174
+ { 'home_files/hero.png': png },
175
+ );
176
+ const blob = await markdownDocsToPlainHtmlBundle({
177
+ entryPath: 'home.md',
178
+ ...c,
179
+ });
180
+ const paths = await listZipPaths(blob);
181
+ expect(paths).toContain('home_files/hero.png');
182
+ const one = await readZipPath(blob, 'chapters/one.html');
183
+ expect(one).toContain('src="../home_files/hero.png"');
184
+ });
185
+
186
+ it('passes the entry title through; sibling docs derive title from the shallowest heading', async () => {
187
+ const c = makeContainer({
188
+ 'home.md': '# Home\n\n[resume](resume.md)\n\n[notes](notes.md)',
189
+ 'resume.md': '# Resume',
190
+ 'notes.md': 'Just a paragraph.',
191
+ });
192
+ const blob = await markdownDocsToPlainHtmlBundle({
193
+ entryPath: 'home.md',
194
+ title: 'My Home Page',
195
+ ...c,
196
+ });
197
+ const home = await readZipPath(blob, 'home.html');
198
+ expect(home).toContain('<title>My Home Page</title>');
199
+ // Heading-bearing sibling uses its heading text, not the filename.
200
+ const resume = await readZipPath(blob, 'resume.html');
201
+ expect(resume).toContain('<title>Resume</title>');
202
+ // Heading-less sibling falls back to the filename.
203
+ const notes = await readZipPath(blob, 'notes.html');
204
+ expect(notes).toContain('<title>notes</title>');
205
+ });
206
+
207
+ it('respects maxDepth — depth 0 means entry only, no link following', async () => {
208
+ const c = makeContainer({
209
+ 'home.md': '# Home\n\n[resume](resume.md)',
210
+ 'resume.md': '# Resume',
211
+ });
212
+ const blob = await markdownDocsToPlainHtmlBundle({
213
+ entryPath: 'home.md',
214
+ maxDepth: 0,
215
+ ...c,
216
+ });
217
+ const paths = await listZipPaths(blob);
218
+ expect(paths).toEqual(['home.html']);
219
+ });
220
+
221
+ it('does not rewrite external links (http://) or fragment-only links', async () => {
222
+ const c = makeContainer({
223
+ 'home.md':
224
+ '[google](https://google.com)\n\n[#section](#somewhere)\n\n[email](mailto:me@example.com)',
225
+ });
226
+ const blob = await markdownDocsToPlainHtmlBundle({
227
+ entryPath: 'home.md',
228
+ ...c,
229
+ });
230
+ const home = await readZipPath(blob, 'home.html');
231
+ expect(home).toContain('href="https://google.com"');
232
+ expect(home).toContain('href="#somewhere"');
233
+ expect(home).toContain('href="mailto:me@example.com"');
234
+ });
235
+ });