@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
|
@@ -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
|
+
}
|
package/src/html/htmlTemplate.ts
CHANGED
|
@@ -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
|
+
* `` (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
|
|
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
|
|
127
|
-
|
|
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';
|