@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.
- package/dist/__tests__/html.test.js +78 -4
- package/dist/__tests__/html.test.js.map +1 -1
- package/dist/__tests__/pdfExport.test.js +1 -1
- package/dist/__tests__/pdfExport.test.js.map +1 -1
- package/dist/__tests__/plainHtml.test.d.ts +5 -0
- package/dist/__tests__/plainHtml.test.d.ts.map +1 -0
- package/dist/__tests__/plainHtml.test.js +327 -0
- package/dist/__tests__/plainHtml.test.js.map +1 -0
- package/dist/__tests__/plainHtmlBundle.test.d.ts +11 -0
- package/dist/__tests__/plainHtmlBundle.test.d.ts.map +1 -0
- package/dist/__tests__/plainHtmlBundle.test.js +210 -0
- package/dist/__tests__/plainHtmlBundle.test.js.map +1 -0
- package/dist/chunk-33YRFXZZ.js +1187 -0
- package/dist/chunk-33YRFXZZ.js.map +1 -0
- package/dist/{chunk-EUQQYBZZ.js → chunk-ERZ627GR.js} +4 -4
- package/dist/chunk-ERZ627GR.js.map +1 -0
- package/dist/{chunk-MEZF76JA.js → chunk-UDS45KUJ.js} +68 -25
- package/dist/chunk-UDS45KUJ.js.map +1 -0
- package/dist/{chunk-NGWHV77G.js → chunk-VN2KEOYB.js} +7 -6
- package/dist/chunk-VN2KEOYB.js.map +1 -0
- package/dist/docx/export.d.ts.map +1 -1
- package/dist/docx/export.js +153 -22
- package/dist/docx/export.js.map +1 -1
- package/dist/epub/export.js +3 -3
- package/dist/epub/export.js.map +1 -1
- package/dist/html/docsHtmlBundle.d.ts +53 -0
- package/dist/html/docsHtmlBundle.d.ts.map +1 -0
- package/dist/html/docsHtmlBundle.js +299 -0
- package/dist/html/docsHtmlBundle.js.map +1 -0
- package/dist/html/htmlTemplate.d.ts.map +1 -1
- package/dist/html/htmlTemplate.js +43 -0
- package/dist/html/htmlTemplate.js.map +1 -1
- package/dist/html/index.d.ts +6 -0
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +31 -3
- package/dist/html/index.js.map +1 -1
- package/dist/html/plainHtml.d.ts +79 -0
- package/dist/html/plainHtml.d.ts.map +1 -0
- package/dist/html/plainHtml.js +614 -0
- package/dist/html/plainHtml.js.map +1 -0
- package/dist/html/plainHtmlBundle.d.ts +93 -0
- package/dist/html/plainHtmlBundle.d.ts.map +1 -0
- package/dist/html/plainHtmlBundle.js +357 -0
- package/dist/html/plainHtmlBundle.js.map +1 -0
- package/dist/pptx/export.d.ts.map +1 -1
- package/dist/pptx/export.js +10 -8
- package/dist/pptx/export.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/html.test.ts +82 -4
- package/src/__tests__/pdfExport.test.ts +1 -1
- package/src/__tests__/plainHtml.test.ts +372 -0
- package/src/__tests__/plainHtmlBundle.test.ts +235 -0
- package/src/docx/export.ts +163 -22
- package/src/epub/export.ts +3 -3
- package/src/html/docsHtmlBundle.ts +369 -0
- package/src/html/htmlTemplate.ts +40 -0
- package/src/html/index.ts +32 -3
- package/src/html/plainHtml.ts +736 -0
- package/src/html/plainHtmlBundle.ts +419 -0
- package/src/pptx/export.ts +10 -9
- package/dist/chunk-EUQQYBZZ.js.map +0 -1
- package/dist/chunk-MEZF76JA.js.map +0 -1
- package/dist/chunk-NGWHV77G.js.map +0 -1
- package/dist/chunk-UM5V2XZG.js +0 -242
- 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.
|
|
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.
|
|
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('
|
|
259
|
-
|
|
260
|
-
|
|
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('
|
|
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 () => {
|
|
@@ -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 <script> & 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('<script>alert(1)</script>');
|
|
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('');
|
|
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('', {
|
|
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('', {
|
|
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('\n\n[c](d"e)');
|
|
117
|
+
expect(html).toContain('"');
|
|
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
|
+
'',
|
|
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\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\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\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\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
|
+
'',
|
|
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
|
+
});
|