@bendyline/squisq-formats 1.2.3 → 1.3.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 (65) hide show
  1. package/dist/__tests__/html.test.js +78 -4
  2. package/dist/__tests__/html.test.js.map +1 -1
  3. package/dist/__tests__/pdfExport.test.js +1 -1
  4. package/dist/__tests__/pdfExport.test.js.map +1 -1
  5. package/dist/__tests__/plainHtml.test.d.ts +5 -0
  6. package/dist/__tests__/plainHtml.test.d.ts.map +1 -0
  7. package/dist/__tests__/plainHtml.test.js +327 -0
  8. package/dist/__tests__/plainHtml.test.js.map +1 -0
  9. package/dist/__tests__/plainHtmlBundle.test.d.ts +11 -0
  10. package/dist/__tests__/plainHtmlBundle.test.d.ts.map +1 -0
  11. package/dist/__tests__/plainHtmlBundle.test.js +210 -0
  12. package/dist/__tests__/plainHtmlBundle.test.js.map +1 -0
  13. package/dist/chunk-33YRFXZZ.js +1187 -0
  14. package/dist/chunk-33YRFXZZ.js.map +1 -0
  15. package/dist/{chunk-EUQQYBZZ.js → chunk-ERZ627GR.js} +4 -4
  16. package/dist/chunk-ERZ627GR.js.map +1 -0
  17. package/dist/{chunk-MEZF76JA.js → chunk-UDS45KUJ.js} +68 -25
  18. package/dist/chunk-UDS45KUJ.js.map +1 -0
  19. package/dist/{chunk-NGWHV77G.js → chunk-VN2KEOYB.js} +7 -6
  20. package/dist/chunk-VN2KEOYB.js.map +1 -0
  21. package/dist/docx/export.d.ts.map +1 -1
  22. package/dist/docx/export.js +153 -22
  23. package/dist/docx/export.js.map +1 -1
  24. package/dist/epub/export.js +3 -3
  25. package/dist/epub/export.js.map +1 -1
  26. package/dist/html/docsHtmlBundle.d.ts +53 -0
  27. package/dist/html/docsHtmlBundle.d.ts.map +1 -0
  28. package/dist/html/docsHtmlBundle.js +299 -0
  29. package/dist/html/docsHtmlBundle.js.map +1 -0
  30. package/dist/html/htmlTemplate.d.ts.map +1 -1
  31. package/dist/html/htmlTemplate.js +43 -0
  32. package/dist/html/htmlTemplate.js.map +1 -1
  33. package/dist/html/index.d.ts +6 -0
  34. package/dist/html/index.d.ts.map +1 -1
  35. package/dist/html/index.js +31 -3
  36. package/dist/html/index.js.map +1 -1
  37. package/dist/html/plainHtml.d.ts +79 -0
  38. package/dist/html/plainHtml.d.ts.map +1 -0
  39. package/dist/html/plainHtml.js +614 -0
  40. package/dist/html/plainHtml.js.map +1 -0
  41. package/dist/html/plainHtmlBundle.d.ts +93 -0
  42. package/dist/html/plainHtmlBundle.d.ts.map +1 -0
  43. package/dist/html/plainHtmlBundle.js +357 -0
  44. package/dist/html/plainHtmlBundle.js.map +1 -0
  45. package/dist/pptx/export.d.ts.map +1 -1
  46. package/dist/pptx/export.js +10 -8
  47. package/dist/pptx/export.js.map +1 -1
  48. package/package.json +3 -2
  49. package/src/__tests__/html.test.ts +82 -4
  50. package/src/__tests__/pdfExport.test.ts +1 -1
  51. package/src/__tests__/plainHtml.test.ts +372 -0
  52. package/src/__tests__/plainHtmlBundle.test.ts +235 -0
  53. package/src/docx/export.ts +163 -22
  54. package/src/epub/export.ts +3 -3
  55. package/src/html/docsHtmlBundle.ts +369 -0
  56. package/src/html/htmlTemplate.ts +40 -0
  57. package/src/html/index.ts +32 -3
  58. package/src/html/plainHtml.ts +736 -0
  59. package/src/html/plainHtmlBundle.ts +419 -0
  60. package/src/pptx/export.ts +10 -9
  61. package/dist/chunk-EUQQYBZZ.js.map +0 -1
  62. package/dist/chunk-MEZF76JA.js.map +0 -1
  63. package/dist/chunk-NGWHV77G.js.map +0 -1
  64. package/dist/chunk-UM5V2XZG.js +0 -242
  65. package/dist/chunk-UM5V2XZG.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-formats",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Document format converters — DOCX, PDF, OOXML import/export",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -80,10 +80,11 @@
80
80
  },
81
81
  "scripts": {
82
82
  "build": "tsup",
83
+ "dev": "concurrently -n js,dts -c blue,gray -r \"tsup --watch --no-dts --no-clean\" \"tsup --watch --dts-only --no-clean\"",
83
84
  "typecheck": "tsc --noEmit"
84
85
  },
85
86
  "dependencies": {
86
- "@bendyline/squisq": "1.3.0",
87
+ "@bendyline/squisq": "1.4.0",
87
88
  "jszip": "3.10.1",
88
89
  "pdf-lib": "1.17.1",
89
90
  "pdfjs-dist": "4.10.38"
@@ -138,6 +138,61 @@ describe('collectImagePaths', () => {
138
138
  const paths = collectImagePaths(doc);
139
139
  expect(paths.has('cover.jpg')).toBe(true);
140
140
  });
141
+
142
+ it('collects markdown image refs from block contents', () => {
143
+ const doc = makeDoc({
144
+ blocks: [
145
+ {
146
+ id: 'b1',
147
+ startTime: 0,
148
+ duration: 5,
149
+ audioSegment: 0,
150
+ contents: [
151
+ {
152
+ type: 'paragraph',
153
+ children: [{ type: 'image', url: 'body.jpg', alt: '' }],
154
+ },
155
+ ] as unknown as Block['contents'],
156
+ },
157
+ ],
158
+ });
159
+ const paths = collectImagePaths(doc);
160
+ expect(paths.has('body.jpg')).toBe(true);
161
+ });
162
+
163
+ it('collects raw HTML <img> refs from block contents (WYSIWYG resize case)', () => {
164
+ // When the WYSIWYG editor serializes a resized image, it emits a raw
165
+ // `<img src width>` tag because markdown shorthand has no width syntax.
166
+ // The parser produces an htmlBlock node with htmlChildren rather than
167
+ // an image node — earlier this was invisible to the export pipeline.
168
+ const doc = makeDoc({
169
+ blocks: [
170
+ {
171
+ id: 'b1',
172
+ startTime: 0,
173
+ duration: 5,
174
+ audioSegment: 0,
175
+ contents: [
176
+ {
177
+ type: 'htmlBlock',
178
+ rawHtml: '<img alt="resized" src="resized.jpg" width="194">',
179
+ htmlChildren: [
180
+ {
181
+ type: 'htmlElement',
182
+ tagName: 'img',
183
+ attributes: { src: 'resized.jpg', alt: 'resized', width: '194' },
184
+ children: [],
185
+ selfClosing: true,
186
+ },
187
+ ],
188
+ },
189
+ ] as unknown as Block['contents'],
190
+ },
191
+ ],
192
+ });
193
+ const paths = collectImagePaths(doc);
194
+ expect(paths.has('resized.jpg')).toBe(true);
195
+ });
141
196
  });
142
197
 
143
198
  // ============================================
@@ -255,13 +310,36 @@ describe('docToHtmlZip', () => {
255
310
  expect(html).not.toContain(MOCK_PLAYER_SCRIPT);
256
311
  });
257
312
 
258
- it('includes images in images/ folder', async () => {
259
- const doc = makeDoc({ blocks: [makeImageBlock('hero.png')] });
260
- const images = new Map([['hero.png', makeImageBuffer()]]);
313
+ it('preserves the original image path inside the zip', async () => {
314
+ // Direct `<img src="folder/file.png">` references in the rendered HTML
315
+ // resolve only if the zip mirrors the doc's path layout — flattening
316
+ // to `images/<basename>` would 404 in the static renderer (which does
317
+ // not rewrite paths through imagePathMap).
318
+ const doc = makeDoc({ blocks: [makeImageBlock('mikehome_files/hero.png')] });
319
+ const images = new Map([['mikehome_files/hero.png', makeImageBuffer()]]);
320
+ const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT, images });
321
+
322
+ const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
323
+ expect(zip.file('mikehome_files/hero.png')).not.toBeNull();
324
+ expect(zip.file('images/hero.png')).toBeNull();
325
+ });
326
+
327
+ it('strips leading slashes and rejects parent-traversal paths', async () => {
328
+ const doc = makeDoc();
329
+ const buffer = makeImageBuffer();
330
+ const images = new Map([
331
+ ['/leading/slash.png', buffer],
332
+ ['../escape.png', buffer],
333
+ ['nested/../sneaky.png', buffer],
334
+ ['ok/path.png', buffer],
335
+ ]);
261
336
  const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT, images });
262
337
 
263
338
  const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
264
- expect(zip.file('images/hero.png')).not.toBeNull();
339
+ expect(zip.file('leading/slash.png')).not.toBeNull();
340
+ expect(zip.file('ok/path.png')).not.toBeNull();
341
+ expect(zip.file('../escape.png')).toBeNull();
342
+ expect(zip.file('nested/../sneaky.png')).toBeNull();
265
343
  });
266
344
 
267
345
  it('includes audio in audio/ folder when provided', async () => {
@@ -311,7 +311,7 @@ describe('docToPdf', () => {
311
311
  startTime: 0,
312
312
  duration: 3,
313
313
  audioSegment: 0,
314
- template: 'titleBlock',
314
+ template: 'title',
315
315
  },
316
316
  ],
317
317
  };
@@ -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
+ });