@bendyline/squisq-formats 1.2.2 → 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
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Recursive rendered-HTML bundle export.
3
+ *
4
+ * `markdownDocsToHtmlBundle` is the rendered-pipeline counterpart of
5
+ * `markdownDocsToPlainHtmlBundle`. Starting from a single entry markdown
6
+ * file, it walks every relative `[…](other.md)` link, builds a Doc for
7
+ * each visited page via `markdownToDoc`, rewrites cross-doc link URLs
8
+ * inside the Doc tree from `.md` → relative `.html`, then renders each
9
+ * Doc to a SquisqPlayer-backed HTML page sharing one `squisq-player.js`.
10
+ * Images preserve their authored relative paths so per-doc folders
11
+ * (`resume_files/hero.png`, etc.) sit beside the rendered HTML.
12
+ *
13
+ * The rewrite happens on the Doc, *before* JSON serialization, because
14
+ * the rendered HTML embeds the Doc as JSON and SquisqPlayer emits the
15
+ * `<a href>` at runtime — there is no post-render link rewrite hook in
16
+ * the player path.
17
+ */
18
+
19
+ import JSZip from 'jszip';
20
+ import { parseMarkdown, inferDocumentTitle } from '@bendyline/squisq/markdown';
21
+ import type {
22
+ MarkdownDocument,
23
+ MarkdownBlockNode,
24
+ MarkdownInlineNode,
25
+ HtmlNode,
26
+ } from '@bendyline/squisq/markdown';
27
+ import { markdownToDoc } from '@bendyline/squisq/doc';
28
+ import type { Block, Doc } from '@bendyline/squisq/schemas';
29
+ import { generateExternalHtml } from './htmlTemplate.js';
30
+ import {
31
+ collectLinkRefs,
32
+ isInScope,
33
+ normalizePath,
34
+ posixDirname,
35
+ relativeFrom,
36
+ resolveRelative,
37
+ } from './plainHtmlBundle.js';
38
+
39
+ // ── Public Types ───────────────────────────────────────────────────
40
+
41
+ export interface HtmlBundleOptions {
42
+ /** Entry document path relative to the container root (e.g. `'home.md'`). */
43
+ entryPath: string;
44
+ /** Reads a UTF-8 markdown file from the container. Returns null when absent. */
45
+ readDocument: (path: string) => Promise<string | null>;
46
+ /** Reads a binary asset (image) from the container. Returns null when absent. */
47
+ readBinary: (path: string) => Promise<ArrayBuffer | null>;
48
+ /** SquisqPlayer IIFE bundle. Written once as `squisq-player.js` in the zip. */
49
+ playerScript: string;
50
+ /** Optional document title for the entry. Other pages derive from filename. */
51
+ title?: string;
52
+ /** Theme id applied uniformly to every page. */
53
+ themeId?: string;
54
+ /** Rendering mode for every page (default: 'static' — scrollable, link-friendly). */
55
+ mode?: 'slideshow' | 'static';
56
+ /** Maximum recursion depth (default: unlimited; cycles always handled). */
57
+ maxDepth?: number;
58
+ /**
59
+ * Emit the entry doc as `index.html` (preserving its parent directory)
60
+ * instead of `<basename>.html`. Cross-doc links pointing at the entry
61
+ * also rewrite to `index.html`, so a sibling `resume.md → home.md`
62
+ * link doesn't 404 after the rename. Convenient for static-site
63
+ * deploys where the landing page must be named `index.html`.
64
+ * Default: false.
65
+ */
66
+ entryAsIndex?: boolean;
67
+ }
68
+
69
+ // ── Public API ─────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Render an entry markdown document and every reachable sibling/child
73
+ * `.md` document it links to, bundled as a single ZIP with one rendered
74
+ * HTML page per doc, per-document asset folders, a shared
75
+ * `squisq-player.js`, and cross-doc `<a href>` references rewritten
76
+ * from `.md` to `.html` inside each Doc's serialized AST.
77
+ */
78
+ export async function markdownDocsToHtmlBundle(options: HtmlBundleOptions): Promise<Blob> {
79
+ const {
80
+ entryPath,
81
+ readDocument,
82
+ readBinary,
83
+ playerScript,
84
+ title,
85
+ themeId,
86
+ mode = 'static',
87
+ maxDepth = Infinity,
88
+ entryAsIndex = false,
89
+ } = options;
90
+
91
+ const entry = normalizePath(entryPath);
92
+ if (!entry) {
93
+ throw new Error('markdownDocsToHtmlBundle: entryPath is required');
94
+ }
95
+ const scopeRoot = posixDirname(entry);
96
+ // When `entryAsIndex`, the entry doc writes to `<entryDir>/index.html`
97
+ // and every cross-doc link pointing at it rewrites to that path too.
98
+ // Other docs keep the `<basename>.html` convention.
99
+ const entryHtmlPath = entryAsIndex
100
+ ? scopeRoot
101
+ ? `${scopeRoot}/index.html`
102
+ : 'index.html'
103
+ : entry.slice(0, -3) + '.html';
104
+ const htmlPathFor = (mdPath: string): string =>
105
+ mdPath === entry ? entryHtmlPath : mdPath.slice(0, -3) + '.html';
106
+
107
+ const zip = new JSZip();
108
+ zip.file('squisq-player.js', playerScript);
109
+
110
+ const visited = new Set<string>();
111
+ const queue: Array<{ path: string; depth: number }> = [{ path: entry, depth: 0 }];
112
+
113
+ while (queue.length > 0) {
114
+ const { path, depth } = queue.shift()!;
115
+ if (visited.has(path)) continue;
116
+ visited.add(path);
117
+
118
+ const source = await readDocument(path);
119
+ if (source === null) {
120
+ throw new Error(`markdownDocsToHtmlBundle: failed to read "${path}"`);
121
+ }
122
+ const mdDoc = parseMarkdown(source);
123
+
124
+ const docDir = posixDirname(path);
125
+ const linkMap = new Map<string, string>();
126
+ for (const raw of collectLinkRefs(mdDoc)) {
127
+ const parsed = parseInternalRef(raw);
128
+ if (!parsed) continue;
129
+ const resolved = resolveRelative(docDir, parsed.path);
130
+ if (resolved === null) continue;
131
+ if (!isInScope(resolved, scopeRoot)) continue;
132
+ if (!resolved.toLowerCase().endsWith('.md')) continue;
133
+
134
+ const htmlTarget = htmlPathFor(resolved);
135
+ const relHref = relativeFrom(docDir, htmlTarget) + parsed.fragment;
136
+ linkMap.set(raw, relHref);
137
+
138
+ if (depth + 1 <= maxDepth && !visited.has(resolved)) {
139
+ queue.push({ path: resolved, depth: depth + 1 });
140
+ }
141
+ }
142
+
143
+ // Gather and stash images per doc. Same convention as the plain
144
+ // bundle: keep authored relative paths so `<img src>` paths inside
145
+ // the Doc still resolve after unzip. Missing assets are non-fatal.
146
+ const imageRewriteMap = new Map<string, string>();
147
+ for (const authored of collectImageRefs(mdDoc)) {
148
+ if (/^[a-z][a-z0-9+.-]*:/i.test(authored) || authored.startsWith('//')) continue;
149
+ const clean = authored.replace(/[#?].*$/, '');
150
+ const resolvedImg = resolveRelative(docDir, clean);
151
+ if (resolvedImg === null) continue;
152
+ const data = await readBinary(resolvedImg);
153
+ if (!data) continue;
154
+ const safe = sanitizeZipPath(resolvedImg);
155
+ if (!safe) continue;
156
+ zip.file(safe, data);
157
+ imageRewriteMap.set(authored, relativeFrom(docDir, resolvedImg));
158
+ }
159
+
160
+ // markdownToDoc embeds the markdown AST in `block.contents`, so the
161
+ // link rewrite must walk that tree on every Doc before we serialize
162
+ // it into the rendered HTML.
163
+ let doc = markdownToDoc(mdDoc);
164
+ if (themeId) doc = { ...doc, themeId };
165
+ if (linkMap.size > 0) {
166
+ doc = rewriteDocLinks(doc, linkMap);
167
+ }
168
+
169
+ const pageTitle = depth === 0 ? (title ?? titleForDoc(path, mdDoc)) : titleForDoc(path, mdDoc);
170
+ const htmlPath = htmlPathFor(path);
171
+ const playerScriptPath = relativeFrom(docDir, 'squisq-player.js');
172
+
173
+ const html = generateExternalHtml(doc, {
174
+ playerScriptPath,
175
+ // Convert Map → plain record for the template (which expects a
176
+ // record). The renderer reads this map at runtime to swap image
177
+ // sources before rendering each block.
178
+ imagePathMap:
179
+ imageRewriteMap.size > 0 ? Object.fromEntries(imageRewriteMap.entries()) : undefined,
180
+ mode,
181
+ title: pageTitle,
182
+ });
183
+ zip.file(htmlPath, html);
184
+ }
185
+
186
+ return zip.generateAsync({
187
+ type: 'blob',
188
+ compression: 'DEFLATE',
189
+ compressionOptions: { level: 6 },
190
+ });
191
+ }
192
+
193
+ // ── Link rewriting ─────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Return a deep-cloned Doc with every `[text](url)` link URL inside
197
+ * `block.contents` rewritten through `linkMap`, including raw HTML
198
+ * `<a href="…">` tags carried inside `htmlBlock`/`htmlInline` nodes.
199
+ * Authoring keys that aren't in the map pass through untouched.
200
+ */
201
+ function rewriteDocLinks(doc: Doc, linkMap: Map<string, string>): Doc {
202
+ if (linkMap.size === 0) return doc;
203
+
204
+ function rewriteHtmlNodes(nodes: HtmlNode[]): HtmlNode[] {
205
+ return nodes.map((n) => {
206
+ if (n.type !== 'htmlElement') return n;
207
+ const tag = n.tagName.toLowerCase();
208
+ const nextAttrs =
209
+ tag === 'a' && typeof n.attributes.href === 'string' && linkMap.has(n.attributes.href)
210
+ ? { ...n.attributes, href: linkMap.get(n.attributes.href)! }
211
+ : n.attributes;
212
+ return {
213
+ ...n,
214
+ attributes: nextAttrs,
215
+ children: rewriteHtmlNodes(n.children),
216
+ };
217
+ });
218
+ }
219
+
220
+ function rewriteInline(node: MarkdownInlineNode): MarkdownInlineNode {
221
+ if (node.type === 'link') {
222
+ const nextUrl = linkMap.get(node.url) ?? node.url;
223
+ return {
224
+ ...node,
225
+ url: nextUrl,
226
+ children: node.children.map(rewriteInline),
227
+ };
228
+ }
229
+ if (node.type === 'htmlInline') {
230
+ const withChildren = node as MarkdownInlineNode & { htmlChildren?: HtmlNode[] };
231
+ if (Array.isArray(withChildren.htmlChildren)) {
232
+ return {
233
+ ...node,
234
+ htmlChildren: rewriteHtmlNodes(withChildren.htmlChildren),
235
+ } as MarkdownInlineNode;
236
+ }
237
+ return node;
238
+ }
239
+ if ('children' in node && Array.isArray((node as { children?: unknown }).children)) {
240
+ const children = (node as { children: MarkdownInlineNode[] }).children.map(rewriteInline);
241
+ return { ...node, children } as MarkdownInlineNode;
242
+ }
243
+ return node;
244
+ }
245
+
246
+ function rewriteBlockNode(node: MarkdownBlockNode): MarkdownBlockNode {
247
+ if (node.type === 'htmlBlock') {
248
+ const withChildren = node as MarkdownBlockNode & { htmlChildren?: HtmlNode[] };
249
+ if (Array.isArray(withChildren.htmlChildren)) {
250
+ return {
251
+ ...node,
252
+ htmlChildren: rewriteHtmlNodes(withChildren.htmlChildren),
253
+ } as MarkdownBlockNode;
254
+ }
255
+ return node;
256
+ }
257
+ if ('children' in node && Array.isArray((node as { children?: unknown }).children)) {
258
+ const rewritten = (node as { children: unknown[] }).children.map((c) => {
259
+ const child = c as { type?: string };
260
+ if (child && typeof child === 'object' && 'type' in child) {
261
+ if (isBlockNodeType(child.type as string)) {
262
+ return rewriteBlockNode(c as MarkdownBlockNode);
263
+ }
264
+ return rewriteInline(c as MarkdownInlineNode);
265
+ }
266
+ return c;
267
+ });
268
+ return { ...node, children: rewritten } as MarkdownBlockNode;
269
+ }
270
+ return node;
271
+ }
272
+
273
+ function rewriteBlock(block: Block): Block {
274
+ const next: Block = { ...block };
275
+ if (Array.isArray(block.contents)) {
276
+ next.contents = block.contents.map(rewriteBlockNode);
277
+ }
278
+ if (Array.isArray(block.children)) {
279
+ next.children = block.children.map(rewriteBlock);
280
+ }
281
+ return next;
282
+ }
283
+
284
+ return {
285
+ ...doc,
286
+ blocks: doc.blocks.map(rewriteBlock),
287
+ };
288
+ }
289
+
290
+ /** Block-level markdown node `type` discriminator. Inline node types are
291
+ * everything else (text, link, emphasis, strong, image, etc.). Keeps
292
+ * the recursive rewrite from mistakenly applying block logic to inline
293
+ * link descendants. */
294
+ function isBlockNodeType(t: string): boolean {
295
+ return (
296
+ t === 'paragraph' ||
297
+ t === 'heading' ||
298
+ t === 'list' ||
299
+ t === 'listItem' ||
300
+ t === 'blockquote' ||
301
+ t === 'code' ||
302
+ t === 'thematicBreak' ||
303
+ t === 'table' ||
304
+ t === 'tableRow' ||
305
+ t === 'tableCell' ||
306
+ t === 'htmlBlock'
307
+ );
308
+ }
309
+
310
+ // ── Helpers (kept local to avoid widening plainHtmlBundle's API) ───
311
+
312
+ interface ParsedRef {
313
+ path: string;
314
+ fragment: string;
315
+ }
316
+
317
+ function parseInternalRef(url: string): ParsedRef | null {
318
+ if (!url) return null;
319
+ if (url.startsWith('#')) return null;
320
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//') || url.startsWith('/')) {
321
+ return null;
322
+ }
323
+ const hashIdx = url.indexOf('#');
324
+ const queryIdx = url.indexOf('?');
325
+ const cut =
326
+ hashIdx >= 0 && queryIdx >= 0 ? Math.min(hashIdx, queryIdx) : hashIdx >= 0 ? hashIdx : queryIdx;
327
+ const path = cut >= 0 ? url.slice(0, cut) : url;
328
+ const fragment = hashIdx >= 0 ? url.slice(hashIdx) : '';
329
+ return { path, fragment };
330
+ }
331
+
332
+ function collectImageRefs(doc: MarkdownDocument): Set<string> {
333
+ const refs = new Set<string>();
334
+ function visitHtml(nodes: HtmlNode[]): void {
335
+ for (const n of nodes) {
336
+ if (n.type !== 'htmlElement') continue;
337
+ if (n.tagName.toLowerCase() === 'img') {
338
+ const src = n.attributes.src;
339
+ if (typeof src === 'string' && src) refs.add(src);
340
+ }
341
+ visitHtml(n.children);
342
+ }
343
+ }
344
+ function visit(node: unknown): void {
345
+ if (!node || typeof node !== 'object') return;
346
+ const n = node as Record<string, unknown>;
347
+ if (n.type === 'image' && typeof n.url === 'string' && n.url) refs.add(n.url);
348
+ if ((n.type === 'htmlBlock' || n.type === 'htmlInline') && Array.isArray(n.htmlChildren)) {
349
+ visitHtml(n.htmlChildren as HtmlNode[]);
350
+ }
351
+ if (Array.isArray(n.children)) for (const c of n.children) visit(c);
352
+ }
353
+ for (const child of doc.children) visit(child);
354
+ return refs;
355
+ }
356
+
357
+ function sanitizeZipPath(path: string): string | null {
358
+ const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
359
+ if (!normalized) return null;
360
+ if (normalized.split('/').some((seg) => seg === '..')) return null;
361
+ return normalized;
362
+ }
363
+
364
+ function titleForDoc(path: string, mdDoc: MarkdownDocument): string {
365
+ const inferred = inferDocumentTitle(mdDoc);
366
+ if (inferred) return inferred;
367
+ const base = path.split('/').pop() ?? path;
368
+ return base.replace(/\.md$/i, '');
369
+ }
@@ -86,6 +86,7 @@ export function collectImagePaths(doc: Doc): Set<string> {
86
86
 
87
87
  function scanBlock(block: Block) {
88
88
  scanLayers(block.layers);
89
+ if (block.contents) scanMarkdownNodes(block.contents, addIfRelative);
89
90
  if (block.children) block.children.forEach(scanBlock);
90
91
  }
91
92
 
@@ -113,6 +114,45 @@ export function collectImagePaths(doc: Doc): Set<string> {
113
114
  return paths;
114
115
  }
115
116
 
117
+ /**
118
+ * Walk markdown body nodes for image references. Catches both shorthand
119
+ * `![alt](url)` (type `image`, `url` field) and raw HTML `<img src>` tags
120
+ * (type `htmlBlock`/`htmlInline`, `htmlChildren` sub-tree) — the latter is
121
+ * how the WYSIWYG editor round-trips resized images, since markdown
122
+ * shorthand has no width syntax.
123
+ */
124
+ function scanMarkdownNodes(nodes: unknown[], visit: (src: string | undefined) => void): void {
125
+ for (const node of nodes) {
126
+ if (!node || typeof node !== 'object') continue;
127
+ const n = node as Record<string, unknown>;
128
+ if (n.type === 'image' && typeof n.url === 'string') {
129
+ visit(n.url);
130
+ }
131
+ if ((n.type === 'htmlBlock' || n.type === 'htmlInline') && Array.isArray(n.htmlChildren)) {
132
+ scanHtmlNodes(n.htmlChildren, visit);
133
+ }
134
+ if (Array.isArray(n.children)) {
135
+ scanMarkdownNodes(n.children, visit);
136
+ }
137
+ }
138
+ }
139
+
140
+ /** Walk a parsed HTML sub-tree for `<img>` src attributes. */
141
+ function scanHtmlNodes(nodes: unknown[], visit: (src: string | undefined) => void): void {
142
+ for (const node of nodes) {
143
+ if (!node || typeof node !== 'object') continue;
144
+ const n = node as Record<string, unknown>;
145
+ if (n.type !== 'htmlElement') continue;
146
+ if (n.tagName === 'img') {
147
+ const attrs = n.attributes as Record<string, string> | undefined;
148
+ if (attrs && typeof attrs.src === 'string') visit(attrs.src);
149
+ }
150
+ if (Array.isArray(n.children)) {
151
+ scanHtmlNodes(n.children, visit);
152
+ }
153
+ }
154
+ }
155
+
116
156
  /**
117
157
  * Recursively scan any object for known image-bearing field names.
118
158
  */
package/src/html/index.ts CHANGED
@@ -42,6 +42,21 @@ import { extractFilename } from './imageUtils.js';
42
42
 
43
43
  export type { HtmlExportOptions };
44
44
 
45
+ /**
46
+ * Normalize a doc-referenced path for use as a zip entry name. Strips
47
+ * leading slashes and rejects any path that tries to escape the archive
48
+ * root via `..` segments (a malicious or malformed doc shouldn't be able
49
+ * to write outside the zip when extracted). Backslashes are folded to
50
+ * forward slashes so Windows-authored paths still produce a consistent
51
+ * tree across extractors.
52
+ */
53
+ function sanitizeZipPath(path: string): string | null {
54
+ const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
55
+ if (!normalized) return null;
56
+ if (normalized.split('/').some((seg) => seg === '..')) return null;
57
+ return normalized;
58
+ }
59
+
45
60
  export interface HtmlZipExportOptions extends HtmlExportOptions {
46
61
  /**
47
62
  * Map of audio segment identifiers to binary audio data.
@@ -119,12 +134,20 @@ export async function docToHtmlZip(doc: Doc, options: HtmlZipExportOptions): Pro
119
134
  // 1. Add player JS as a separate file
120
135
  zip.file('squisq-player.js', playerScript);
121
136
 
122
- // 2. Add images to images/ folder and build path mapping
137
+ // 2. Add images preserving their original relative paths.
138
+ //
139
+ // We used to flatten everything into `images/<basename>`, but that
140
+ // requires the player to rewrite every `<img src>` at render time using
141
+ // the path map — and the static (non-slideshow) renderer doesn't do
142
+ // that, so its emitted `<img>` tags 404'd against the renamed assets.
143
+ // Keeping the original layout means direct `src="folder/file.png"`
144
+ // references just work, and the exported zip mirrors how the source
145
+ // folder is organized (e.g. pandoc-style `notes_files/` sidecars).
123
146
  const imagePathMap: Record<string, string> = {};
124
147
  if (images) {
125
148
  for (const [originalPath, buffer] of images.entries()) {
126
- const filename = extractFilename(originalPath);
127
- const zipPath = `images/${filename}`;
149
+ const zipPath = sanitizeZipPath(originalPath);
150
+ if (!zipPath) continue;
128
151
  zip.file(zipPath, buffer);
129
152
  imagePathMap[originalPath] = zipPath;
130
153
  }
@@ -166,3 +189,9 @@ export async function docToHtmlZip(doc: Doc, options: HtmlZipExportOptions): Pro
166
189
 
167
190
  export { collectImagePaths } from './htmlTemplate.js';
168
191
  export { inferMimeType, arrayBufferToBase64DataUrl, extractFilename } from './imageUtils.js';
192
+ export { markdownDocToPlainHtml } from './plainHtml.js';
193
+ export type { PlainHtmlExportOptions } from './plainHtml.js';
194
+ export { markdownDocsToPlainHtmlBundle, collectLinkRefs } from './plainHtmlBundle.js';
195
+ export type { PlainHtmlBundleOptions } from './plainHtmlBundle.js';
196
+ export { markdownDocsToHtmlBundle } from './docsHtmlBundle.js';
197
+ export type { HtmlBundleOptions } from './docsHtmlBundle.js';