@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,736 @@
1
+ /**
2
+ * Plain HTML Export — semantic, player-free
3
+ *
4
+ * Renders a MarkdownDocument to a self-contained HTML string. Unlike
5
+ * `docToHtml` (which bundles the SquisqPlayer IIFE and renders SVG block
6
+ * cards), this output is what a reader-mode tool would produce: semantic
7
+ * `<h1>`/`<p>`/`<ul>`/etc. with a small embedded stylesheet, no JS, no
8
+ * runtime path-rewriting. Drives both the "Page" preview tab in the
9
+ * editor and the plain-style branch of the export dialog so what users
10
+ * see live matches the file they download.
11
+ *
12
+ * Image URLs default to the markdown's own paths. Pass `images` to
13
+ * substitute a different value per source URL — typically pre-resolved
14
+ * blob URLs (live preview) or data URIs (single-file export).
15
+ */
16
+
17
+ import type { MarkdownDocument, MarkdownNode, HtmlNode } from '@bendyline/squisq/markdown';
18
+ import type { Theme } from '@bendyline/squisq/schemas';
19
+ import { resolveFontFamily, buildGoogleFontsUrl, resolveTheme } from '@bendyline/squisq/schemas';
20
+
21
+ // ── Public Types ───────────────────────────────────────────────────
22
+
23
+ export interface PlainHtmlExportOptions {
24
+ /** Document title — populates `<title>` and is HTML-escaped. */
25
+ title?: string;
26
+ /**
27
+ * Substitution map for image `src` URLs. Keys are the URL exactly as
28
+ * it appears in the markdown source; values are the URL to emit in
29
+ * the rendered `<img src>`. URLs not present in the map fall through
30
+ * unchanged (so external `https://…` references still work).
31
+ */
32
+ images?: Map<string, string>;
33
+ /**
34
+ * Substitution map for anchor `href` URLs. Keys are the URL exactly
35
+ * as it appears in the markdown source (e.g. `'resume.md'`,
36
+ * `'resume.md#experience'`); values are the URL to emit. URLs not in
37
+ * the map pass through unchanged. Used by the recursive bundle
38
+ * exporter to rewrite `.md` references to `.html` so a static export
39
+ * of a linked document tree is internally browsable.
40
+ */
41
+ links?: Map<string, string>;
42
+ /**
43
+ * Optional Squisq theme. When provided, the rendered page uses the
44
+ * theme's colors and typography, and any Google-hosted fonts the
45
+ * theme references are loaded via a `<link>` to fonts.googleapis.com
46
+ * so the face renders correctly without host preloads.
47
+ *
48
+ * When omitted, the function falls back (in order) to {@link themeId}
49
+ * and then to `doc.frontmatter.themeId` — so an authored
50
+ * `themeId: warm-earth` in the doc's frontmatter styles the export
51
+ * automatically, without the caller having to wire theme resolution
52
+ * themselves.
53
+ */
54
+ theme?: Theme;
55
+ /**
56
+ * Optional theme id (e.g. `'warm-earth'`, `'gezellig'`). Convenient
57
+ * for hosts whose export dialog tracks themes by id — they can pass
58
+ * the id straight through instead of resolving to a `Theme` object.
59
+ * When both `theme` and `themeId` are provided, `theme` wins.
60
+ */
61
+ themeId?: string;
62
+ /**
63
+ * Optional FontAwesome CSS text to inline into the rendered page,
64
+ * replacing the default cross-origin `<link>` to cdnjs. Required for
65
+ * sandboxed iframe previews where tracking prevention or stricter
66
+ * origin policies can silently drop cross-origin font fetches —
67
+ * inlining keeps the icons resolvable purely from same-origin
68
+ * resources. Hosts typically gather this string by scraping
69
+ * `document.styleSheets` for `@font-face` rules whose family starts
70
+ * with `"Font Awesome"`. The CDN `<link>` is only emitted when this
71
+ * option is not provided.
72
+ */
73
+ iconsCss?: string;
74
+ }
75
+
76
+ /**
77
+ * Internal render context — bundles the substitution maps so adding a
78
+ * new one (e.g. `links`) doesn't require threading another parameter
79
+ * through every node renderer.
80
+ */
81
+ interface RenderCtx {
82
+ images?: Map<string, string>;
83
+ links?: Map<string, string>;
84
+ }
85
+
86
+ // ── Public API ─────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Render a parsed markdown document as a complete, semantic HTML page.
90
+ *
91
+ * When `options.theme` is provided, the output adopts the theme's
92
+ * colors and typography. Google-hosted fonts referenced by the theme
93
+ * are loaded via a single `<link>` to fonts.googleapis.com so the page
94
+ * renders consistently when opened standalone.
95
+ */
96
+ export function markdownDocToPlainHtml(
97
+ doc: MarkdownDocument,
98
+ options: PlainHtmlExportOptions = {},
99
+ ): string {
100
+ const { title = 'Document', images, links, themeId, iconsCss } = options;
101
+ // Fall back chain for theme: explicit `theme` → explicit `themeId`
102
+ // option → doc frontmatter `themeId`. Hosts whose export dialog
103
+ // tracks themes by id can pass `themeId` straight through; authored
104
+ // docs with `themeId: warm-earth` in frontmatter get styled
105
+ // automatically when neither is supplied.
106
+ const theme =
107
+ options.theme ??
108
+ (themeId ? resolveTheme(themeId) : undefined) ??
109
+ (typeof doc.frontmatter?.themeId === 'string'
110
+ ? resolveTheme(doc.frontmatter.themeId)
111
+ : undefined);
112
+ const ctx: RenderCtx = { images, links };
113
+ const body = renderTopLevel(doc.children, ctx);
114
+ const fontsLink = theme ? renderFontsLink(theme) : '';
115
+ // Resolve how to load FontAwesome — only when the doc actually uses
116
+ // icons. When the host supplies `iconsCss` (typical for sandboxed
117
+ // iframe previews where cross-origin font fetches get blocked), we
118
+ // inline it as a `<style>` block; otherwise we fall back to the
119
+ // public cdnjs `<link>` which works for standalone HTML files.
120
+ const usesIcons = docUsesIcons(doc);
121
+ let iconsLink = '';
122
+ if (usesIcons) {
123
+ iconsLink = iconsCss ? `<style data-fa-inline>\n${iconsCss}\n</style>\n` : FONT_AWESOME_LINK;
124
+ }
125
+ const themedCss = theme ? renderThemedCss(theme) : DEFAULT_CSS;
126
+ return `<!DOCTYPE html>
127
+ <html lang="en">
128
+ <head>
129
+ <meta charset="UTF-8">
130
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
131
+ <title>${escapeHtml(title)}</title>
132
+ ${fontsLink}${iconsLink}<style>
133
+ ${themedCss}
134
+ ${FEATURE_CSS}
135
+ </style>
136
+ </head>
137
+ <body>
138
+ ${body}
139
+ </body>
140
+ </html>`;
141
+ }
142
+
143
+ /**
144
+ * Hosted FontAwesome Free CSS. Pinned to a specific release so the
145
+ * integrity hash stays in sync — bump both fields together when
146
+ * upgrading. Cdnjs serves the matching SRI hash on every release page.
147
+ */
148
+ const FONT_AWESOME_LINK = `<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
149
+ `;
150
+
151
+ /** Walk the doc looking for any `inlineIcon` node. Cheap depth-first
152
+ * traversal — bails out at the first hit. */
153
+ function docUsesIcons(doc: MarkdownDocument): boolean {
154
+ function visit(node: unknown): boolean {
155
+ if (!node || typeof node !== 'object') return false;
156
+ const n = node as Record<string, unknown>;
157
+ if (n.type === 'inlineIcon') return true;
158
+ if (Array.isArray(n.children)) {
159
+ for (const child of n.children) if (visit(child)) return true;
160
+ }
161
+ return false;
162
+ }
163
+ return visit(doc);
164
+ }
165
+
166
+ // ── Top-level walk with feature-section grouping ───────────────────
167
+
168
+ /**
169
+ * Walk the document's top-level children, grouping headings that carry
170
+ * a `leftFeature` / `rightFeature` template annotation with the body
171
+ * blocks that follow them (up to the next sibling-or-higher heading).
172
+ * Each group renders as a single `<section class="squisq-feature ...">`
173
+ * with a media column and a text column — the plain-HTML analogue of
174
+ * the SVG layer layout produced by `getLayers` for the same templates.
175
+ */
176
+ function renderTopLevel(children: MarkdownNode[], ctx: RenderCtx | undefined): string {
177
+ const out: string[] = [];
178
+ for (let i = 0; i < children.length; i++) {
179
+ const node = children[i] as { type?: string };
180
+ if (node && node.type === 'heading') {
181
+ const heading = node as MarkdownHeadingLike;
182
+ const tpl = heading.templateAnnotation?.template;
183
+ if (tpl === 'leftFeature' || tpl === 'rightFeature') {
184
+ const end = findSectionEnd(children, i);
185
+ const sectionBody = children.slice(i + 1, end);
186
+ out.push(renderFeatureSection(heading, sectionBody, tpl, ctx));
187
+ i = end - 1;
188
+ continue;
189
+ }
190
+ }
191
+ out.push(nodeToHtml(node as MarkdownNode, ctx));
192
+ }
193
+ return out.join('\n');
194
+ }
195
+
196
+ interface MarkdownHeadingLike {
197
+ type: 'heading';
198
+ depth: number;
199
+ children?: MarkdownNode[];
200
+ templateAnnotation?: { template?: string };
201
+ }
202
+
203
+ /**
204
+ * Index of the next heading after `from`; otherwise the array length.
205
+ * Feature sections greedily eat the heading's immediate body content
206
+ * (paragraphs, lists, images) up to the next heading of any depth — a
207
+ * nested sub-heading inside a feature would look chaotic in the
208
+ * side-by-side layout, so we keep features short by design.
209
+ */
210
+ function findSectionEnd(nodes: MarkdownNode[], from: number): number {
211
+ for (let i = from + 1; i < nodes.length; i++) {
212
+ const n = nodes[i] as { type?: string };
213
+ if (n && n.type === 'heading') return i;
214
+ }
215
+ return nodes.length;
216
+ }
217
+
218
+ /**
219
+ * Render a feature section as `<section class="squisq-feature ...">`.
220
+ * The first image found in the body becomes the media column; the
221
+ * heading + remaining content (with the image stripped, so it doesn't
222
+ * appear twice) becomes the text column.
223
+ */
224
+ function renderFeatureSection(
225
+ heading: MarkdownHeadingLike,
226
+ bodyNodes: MarkdownNode[],
227
+ side: 'leftFeature' | 'rightFeature',
228
+ ctx: RenderCtx | undefined,
229
+ ): string {
230
+ const headingTag = `h${Math.min(Math.max(heading.depth ?? 2, 1), 6)}`;
231
+ const headingHtml = `<${headingTag}>${childrenToHtml({ children: heading.children }, ctx)}</${headingTag}>`;
232
+
233
+ const featured = takeFirstImage(bodyNodes);
234
+ const media = featured.image
235
+ ? renderFeatureImage(featured.image, ctx)
236
+ : '<div class="squisq-feature__media squisq-feature__media--empty"></div>';
237
+
238
+ const textHtml = [headingHtml, ...featured.remaining.map((n) => nodeToHtml(n, ctx))]
239
+ .filter((s) => s.length > 0)
240
+ .join('\n');
241
+
242
+ const sideClass = side === 'leftFeature' ? 'squisq-feature--left' : 'squisq-feature--right';
243
+
244
+ return `<section class="squisq-feature ${sideClass}">
245
+ ${media}
246
+ <div class="squisq-feature__body">
247
+ ${textHtml}
248
+ </div>
249
+ </section>`;
250
+ }
251
+
252
+ interface FeaturedImage {
253
+ src: string;
254
+ alt: string;
255
+ width?: number;
256
+ height?: number;
257
+ }
258
+
259
+ function renderFeatureImage(img: FeaturedImage, ctx: RenderCtx | undefined): string {
260
+ const resolved = ctx?.images?.get(img.src) ?? img.src;
261
+ const attrs = [`src="${escapeAttr(resolved)}"`, `alt="${escapeAttr(img.alt)}"`];
262
+ // Emit `width` / `height` attributes only when the source HTML had
263
+ // them. The CSS rules then know to honor those values rather than
264
+ // stretching the image to fill the column.
265
+ if (typeof img.width === 'number') attrs.push(`width="${img.width}"`);
266
+ if (typeof img.height === 'number') attrs.push(`height="${img.height}"`);
267
+ const sizedClass =
268
+ typeof img.width === 'number' || typeof img.height === 'number'
269
+ ? ' squisq-feature__media--sized'
270
+ : '';
271
+ return `<div class="squisq-feature__media${sizedClass}"><img ${attrs.join(' ')} /></div>`;
272
+ }
273
+
274
+ /**
275
+ * Pull the first image reference out of a section's body — either a
276
+ * markdown `image` node (possibly nested inside a paragraph) or a raw
277
+ * HTML `<img>` (the WYSIWYG editor emits these for resized images).
278
+ * Returns the image plus the body with that image removed (so it's not
279
+ * rendered a second time inside the text column).
280
+ */
281
+ function takeFirstImage(nodes: MarkdownNode[]): {
282
+ image: FeaturedImage | null;
283
+ remaining: MarkdownNode[];
284
+ } {
285
+ for (let i = 0; i < nodes.length; i++) {
286
+ const found = extractFirstImageFromBlock(nodes[i]);
287
+ if (!found) continue;
288
+ const remaining = [...nodes];
289
+ if (found.replacement === null) {
290
+ remaining.splice(i, 1);
291
+ } else {
292
+ remaining[i] = found.replacement;
293
+ }
294
+ return { image: found.image, remaining };
295
+ }
296
+ return { image: null, remaining: nodes };
297
+ }
298
+
299
+ /**
300
+ * Look for the first image inside a single block node. If the block is
301
+ * a paragraph that contains only the image (with optional whitespace),
302
+ * we drop the whole paragraph; if there's surrounding text, we strip
303
+ * the image from the paragraph's inline children and keep the rest.
304
+ * For raw `<img>` html blocks we drop the block entirely.
305
+ */
306
+ function extractFirstImageFromBlock(
307
+ block: MarkdownNode,
308
+ ): { image: FeaturedImage; replacement: MarkdownNode | null } | null {
309
+ if (!block || typeof block !== 'object') return null;
310
+ const b = block as unknown as Record<string, unknown>;
311
+ if (b.type === 'paragraph' && Array.isArray(b.children)) {
312
+ const kids = b.children as MarkdownNode[];
313
+ const imgIdx = kids.findIndex(
314
+ (k) =>
315
+ (k as { type?: string }).type === 'image' &&
316
+ typeof (k as { url?: unknown }).url === 'string',
317
+ );
318
+ if (imgIdx >= 0) {
319
+ const img = kids[imgIdx] as { url: string; alt?: string };
320
+ const remainingKids = [...kids.slice(0, imgIdx), ...kids.slice(imgIdx + 1)].filter(
321
+ (k) => !isBlankInline(k),
322
+ );
323
+ const replacement =
324
+ remainingKids.length === 0
325
+ ? null
326
+ : ({ ...(b as object), children: remainingKids } as MarkdownNode);
327
+ return {
328
+ image: { src: img.url, alt: img.alt ?? '' },
329
+ replacement,
330
+ };
331
+ }
332
+ }
333
+ if (b.type === 'htmlBlock' && Array.isArray(b.htmlChildren)) {
334
+ const hit = findHtmlImg(b.htmlChildren as HtmlNode[]);
335
+ if (hit) return { image: hit, replacement: null };
336
+ }
337
+ return null;
338
+ }
339
+
340
+ function isBlankInline(node: MarkdownNode): boolean {
341
+ if (!node || typeof node !== 'object') return false;
342
+ const n = node as { type?: string; value?: unknown };
343
+ return n.type === 'text' && typeof n.value === 'string' && n.value.trim().length === 0;
344
+ }
345
+
346
+ function findHtmlImg(nodes: HtmlNode[]): FeaturedImage | null {
347
+ for (const n of nodes) {
348
+ if (n.type !== 'htmlElement') continue;
349
+ if (n.tagName.toLowerCase() === 'img') {
350
+ const src = n.attributes.src;
351
+ if (typeof src === 'string' && src) {
352
+ return {
353
+ src,
354
+ alt: typeof n.attributes.alt === 'string' ? n.attributes.alt : '',
355
+ width: parseHtmlDim(n.attributes.width),
356
+ height: parseHtmlDim(n.attributes.height),
357
+ };
358
+ }
359
+ }
360
+ const nested = findHtmlImg(n.children);
361
+ if (nested) return nested;
362
+ }
363
+ return null;
364
+ }
365
+
366
+ function parseHtmlDim(raw: string | undefined): number | undefined {
367
+ if (raw === undefined) return undefined;
368
+ const n = parseFloat(raw);
369
+ return Number.isFinite(n) && n > 0 ? n : undefined;
370
+ }
371
+
372
+ const FEATURE_CSS = ` .squisq-feature {
373
+ display: flex;
374
+ flex-wrap: wrap;
375
+ gap: 1.5em;
376
+ align-items: center;
377
+ margin: 1.75em 0;
378
+ }
379
+ .squisq-feature--right { flex-direction: row-reverse; }
380
+ .squisq-feature__media {
381
+ flex: 0 0 42%;
382
+ min-width: 0;
383
+ }
384
+ .squisq-feature__media--empty { display: none; }
385
+ /* Default: image fills the media column. */
386
+ .squisq-feature__media img {
387
+ width: 100%;
388
+ height: auto;
389
+ display: block;
390
+ border-radius: 6px;
391
+ }
392
+ /* "Sized" media: the image carried explicit width/height attrs (the
393
+ WYSIWYG editor wrote them after a resize). Honor the attribute
394
+ values and center the image inside the media column with padding. */
395
+ .squisq-feature__media--sized {
396
+ display: flex;
397
+ align-items: center;
398
+ justify-content: center;
399
+ padding: 1em;
400
+ box-sizing: border-box;
401
+ }
402
+ /* Leave width and height untouched here -- the HTML attributes set
403
+ the intrinsic dimensions, and CSS overrides would silently discard
404
+ the author's sizing. max-width: 100% still keeps the image from
405
+ overflowing the column on narrow viewports; HTML5 derives the
406
+ aspect ratio from the width/height pair so the scale stays right. */
407
+ .squisq-feature__media--sized img {
408
+ max-width: 100%;
409
+ display: block;
410
+ border-radius: 6px;
411
+ }
412
+ .squisq-feature__body {
413
+ flex: 1 1 0;
414
+ min-width: 0;
415
+ }
416
+ .squisq-feature__body > :first-child { margin-top: 0; }
417
+ .squisq-feature__body > :last-child { margin-bottom: 0; }
418
+ @media (max-width: 600px) {
419
+ .squisq-feature, .squisq-feature--right { flex-direction: column; }
420
+ .squisq-feature__media { flex-basis: auto; width: 100%; }
421
+ }`;
422
+
423
+ // ── Theme-driven CSS ───────────────────────────────────────────────
424
+
425
+ const DEFAULT_CSS = ` body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6; color: #1f2937; }
426
+ h1, h2, h3, h4, h5, h6 { margin-top: 1.5em; margin-bottom: 0.5em; }
427
+ pre { background: #f3f4f6; padding: 1em; border-radius: 4px; overflow-x: auto; }
428
+ code { background: #f3f4f6; padding: 0.15em 0.3em; border-radius: 3px; font-size: 0.9em; }
429
+ pre code { background: none; padding: 0; }
430
+ blockquote { border-left: 3px solid #d1d5db; margin-left: 0; padding-left: 1em; color: #6b7280; }
431
+ /* Images: cap at the container width so nothing overflows, but only
432
+ force aspect-ratio height when the author didn't set explicit
433
+ dimensions on the <img> tag. The WYSIWYG editor writes width/height
434
+ attributes after a resize — overriding them here would silently
435
+ ignore the user's sizing. */
436
+ img { max-width: 100%; }
437
+ img:not([width]):not([height]) { height: auto; }
438
+ a { color: #3b82f6; }
439
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
440
+ th, td { border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }
441
+ th { background: #f3f4f6; font-weight: 600; }
442
+ hr { border: none; border-top: 1px solid #d1d5db; margin: 1.5em 0; }`;
443
+
444
+ /**
445
+ * Build the stylesheet for a themed page. We resolve fonts and colors
446
+ * to CSS-ready strings up front (rather than emitting `--squisq-*`
447
+ * custom properties everywhere) so the output works in environments
448
+ * that strip CSS variables.
449
+ */
450
+ function renderThemedCss(theme: Theme): string {
451
+ const bodyFamily = resolveFontFamily(theme.typography.bodyFont, 'system-ui, sans-serif');
452
+ const titleFamily = resolveFontFamily(theme.typography.titleFont, 'Georgia, serif');
453
+ const monoFamily = resolveFontFamily(theme.typography.monoFont, 'Consolas, monospace');
454
+ const lineHeight = theme.typography.lineHeight ?? 1.6;
455
+ const titleLineHeight = theme.typography.titleLineHeight ?? 1.25;
456
+ const titleWeight = theme.typography.titleWeight === 'normal' ? 400 : 700;
457
+ const c = theme.colors;
458
+ // A few derived colors so the page doesn't end up unreadable on
459
+ // themes that swing dark (most code/table chrome reads as "dim panel
460
+ // on background"). We do this in CSS, not in JS, by mixing with
461
+ // `color-mix` so older browsers without it still get a sensible look.
462
+ return ` :root {
463
+ --plain-bg: ${c.background};
464
+ --plain-text: ${c.text};
465
+ --plain-muted: ${c.textMuted};
466
+ --plain-primary: ${c.primary};
467
+ --plain-secondary: ${c.secondary};
468
+ --plain-accent: ${c.highlight};
469
+ --plain-bg-light: ${c.backgroundLight};
470
+ --plain-body-font: ${bodyFamily};
471
+ --plain-title-font: ${titleFamily};
472
+ --plain-mono-font: ${monoFamily};
473
+ }
474
+ body {
475
+ font-family: var(--plain-body-font);
476
+ max-width: 800px;
477
+ margin: 2em auto;
478
+ padding: 0 1em;
479
+ line-height: ${lineHeight};
480
+ color: var(--plain-text);
481
+ background: var(--plain-bg);
482
+ }
483
+ h1, h2, h3, h4, h5, h6 {
484
+ font-family: var(--plain-title-font);
485
+ color: var(--plain-text);
486
+ margin-top: 1.5em;
487
+ margin-bottom: 0.5em;
488
+ line-height: ${titleLineHeight};
489
+ font-weight: ${titleWeight};
490
+ }
491
+ p { margin: 0.75em 0; }
492
+ pre {
493
+ background: var(--plain-bg-light);
494
+ padding: 1em;
495
+ border-radius: 4px;
496
+ overflow-x: auto;
497
+ font-family: var(--plain-mono-font);
498
+ }
499
+ code {
500
+ background: var(--plain-bg-light);
501
+ padding: 0.15em 0.3em;
502
+ border-radius: 3px;
503
+ font-size: 0.9em;
504
+ font-family: var(--plain-mono-font);
505
+ }
506
+ pre code { background: none; padding: 0; }
507
+ blockquote {
508
+ border-left: 3px solid var(--plain-primary);
509
+ margin-left: 0;
510
+ padding-left: 1em;
511
+ color: var(--plain-muted);
512
+ }
513
+ /* See DEFAULT_CSS comment: only auto-scale height when the author
514
+ didn't set explicit width/height attributes. */
515
+ img { max-width: 100%; }
516
+ img:not([width]):not([height]) { height: auto; }
517
+ /* Blend the theme primary toward the body text color so links stay
518
+ theme-flavored but read clearly on every theme's background — some
519
+ themes (e.g. Gezellig) pick a mid-tone primary that's almost
520
+ invisible on a dark page when used neat. Underline makes the link
521
+ unambiguous independent of the color contrast. */
522
+ a {
523
+ color: color-mix(in srgb, var(--plain-primary) 65%, var(--plain-text));
524
+ text-decoration: underline;
525
+ text-decoration-thickness: 1px;
526
+ text-underline-offset: 2px;
527
+ }
528
+ a:hover { color: var(--plain-accent); }
529
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
530
+ th, td {
531
+ border: 1px solid color-mix(in srgb, var(--plain-muted) 30%, transparent);
532
+ padding: 6px 10px;
533
+ text-align: left;
534
+ }
535
+ th {
536
+ background: var(--plain-bg-light);
537
+ color: var(--plain-text);
538
+ font-family: var(--plain-title-font);
539
+ font-weight: 600;
540
+ }
541
+ hr {
542
+ border: none;
543
+ border-top: 1px solid color-mix(in srgb, var(--plain-muted) 40%, transparent);
544
+ margin: 1.5em 0;
545
+ }`;
546
+ }
547
+
548
+ /**
549
+ * Emit the `<link rel="stylesheet">` line(s) needed to load the theme's
550
+ * fonts. Empty string when nothing in the theme is hosted on Google
551
+ * Fonts (system stacks, custom self-hosted faces).
552
+ */
553
+ function renderFontsLink(theme: Theme): string {
554
+ const url = buildGoogleFontsUrl([
555
+ theme.typography.bodyFont,
556
+ theme.typography.titleFont,
557
+ theme.typography.monoFont,
558
+ ]);
559
+ if (!url) return '';
560
+ // Preconnect to Google's CDN to shave a roundtrip off the font load.
561
+ return `<link rel="preconnect" href="https://fonts.googleapis.com">
562
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
563
+ <link rel="stylesheet" href="${escapeAttr(url)}">
564
+ `;
565
+ }
566
+
567
+ // ── Node → HTML ────────────────────────────────────────────────────
568
+
569
+ function nodeToHtml(node: MarkdownNode | undefined | null, ctx?: RenderCtx): string {
570
+ if (!node) return '';
571
+ switch (node.type) {
572
+ case 'heading': {
573
+ const depth = Math.min(Math.max(node.depth ?? 1, 1), 6);
574
+ return `<h${depth}>${childrenToHtml(node, ctx)}</h${depth}>`;
575
+ }
576
+ case 'paragraph':
577
+ return `<p>${childrenToHtml(node, ctx)}</p>`;
578
+ case 'text':
579
+ return escapeHtml(node.value ?? '');
580
+ case 'strong':
581
+ return `<strong>${childrenToHtml(node, ctx)}</strong>`;
582
+ case 'emphasis':
583
+ return `<em>${childrenToHtml(node, ctx)}</em>`;
584
+ case 'delete':
585
+ return `<del>${childrenToHtml(node, ctx)}</del>`;
586
+ case 'inlineCode':
587
+ return `<code>${escapeHtml(node.value ?? '')}</code>`;
588
+ case 'code': {
589
+ const lang = node.lang ? ` class="language-${escapeAttr(node.lang)}"` : '';
590
+ return `<pre><code${lang}>${escapeHtml(node.value ?? '')}</code></pre>`;
591
+ }
592
+ case 'blockquote':
593
+ return `<blockquote>${childrenToHtml(node, ctx)}</blockquote>`;
594
+ case 'list': {
595
+ const tag = node.ordered ? 'ol' : 'ul';
596
+ const start =
597
+ node.ordered && typeof node.start === 'number' && node.start !== 1
598
+ ? ` start="${node.start}"`
599
+ : '';
600
+ return `<${tag}${start}>${childrenToHtml(node, ctx)}</${tag}>`;
601
+ }
602
+ case 'listItem':
603
+ return `<li>${childrenToHtml(node, ctx)}</li>`;
604
+ case 'link': {
605
+ const original = node.url ?? '';
606
+ const rewritten = ctx?.links?.get(original) ?? original;
607
+ return `<a href="${escapeAttr(rewritten)}">${childrenToHtml(node, ctx)}</a>`;
608
+ }
609
+ case 'image': {
610
+ const original = node.url ?? '';
611
+ const resolved = ctx?.images?.get(original) ?? original;
612
+ return `<img src="${escapeAttr(resolved)}" alt="${escapeAttr(node.alt ?? '')}" />`;
613
+ }
614
+ case 'thematicBreak':
615
+ return '<hr />';
616
+ case 'table':
617
+ return tableToHtml(node, ctx);
618
+ case 'inlineIcon': {
619
+ // Render via FontAwesome's `fa-<family> fa-<name>` class pair.
620
+ // The `<link>` to the FA CSS is injected into <head> by
621
+ // `markdownDocToPlainHtml` when the doc contains any icon.
622
+ const family = escapeAttr(node.family ?? 'solid');
623
+ const name = escapeAttr(node.name ?? '');
624
+ const token = escapeAttr(node.token ?? `${node.family}:${node.name}`);
625
+ return `<i class="fa-${family} fa-${name}" data-icon="${token}" aria-hidden="true"></i>`;
626
+ }
627
+ case 'htmlBlock':
628
+ case 'htmlInline':
629
+ // Resized images and other authored HTML survive the round-trip
630
+ // as parsed `htmlChildren` — rewriting `<img src>` through the
631
+ // image map keeps the preview consistent with the markdown-image
632
+ // path. Other tags pass through unmodified.
633
+ return htmlChildrenToHtml(node.htmlChildren, ctx);
634
+ default: {
635
+ // Unknown / unhandled node — recurse into children if any so we
636
+ // don't drop content (e.g. directives, footnotes).
637
+ const withChildren = node as { children?: unknown; value?: unknown };
638
+ if (Array.isArray(withChildren.children)) {
639
+ return childrenToHtml(node as { children: MarkdownNode[] }, ctx);
640
+ }
641
+ if (typeof withChildren.value === 'string') {
642
+ return escapeHtml(withChildren.value);
643
+ }
644
+ return '';
645
+ }
646
+ }
647
+ }
648
+
649
+ function childrenToHtml(
650
+ node: { children?: MarkdownNode[]; value?: string },
651
+ ctx?: RenderCtx,
652
+ ): string {
653
+ if (!node.children) return node.value ? escapeHtml(node.value) : '';
654
+ return node.children.map((child) => nodeToHtml(child, ctx)).join('');
655
+ }
656
+
657
+ function tableToHtml(
658
+ node: { children: { children: { children: MarkdownNode[]; isHeader?: boolean }[] }[] },
659
+ ctx?: RenderCtx,
660
+ ): string {
661
+ const [headerRow, ...bodyRows] = node.children;
662
+ const parts: string[] = ['<table>'];
663
+ if (headerRow) {
664
+ parts.push('<thead><tr>');
665
+ for (const cell of headerRow.children) {
666
+ parts.push(`<th>${childrenToHtml(cell, ctx)}</th>`);
667
+ }
668
+ parts.push('</tr></thead>');
669
+ }
670
+ if (bodyRows.length > 0) {
671
+ parts.push('<tbody>');
672
+ for (const row of bodyRows) {
673
+ parts.push('<tr>');
674
+ for (const cell of row.children) {
675
+ parts.push(`<td>${childrenToHtml(cell, ctx)}</td>`);
676
+ }
677
+ parts.push('</tr>');
678
+ }
679
+ parts.push('</tbody>');
680
+ }
681
+ parts.push('</table>');
682
+ return parts.join('');
683
+ }
684
+
685
+ function htmlChildrenToHtml(nodes: HtmlNode[] | undefined, ctx?: RenderCtx): string {
686
+ if (!nodes || nodes.length === 0) return '';
687
+ const out: string[] = [];
688
+ for (const node of nodes) {
689
+ if (node.type === 'htmlText') {
690
+ // HtmlText already represents authored HTML — emit verbatim so
691
+ // entity references the user wrote (e.g. `&amp;`) survive.
692
+ out.push(node.value);
693
+ continue;
694
+ }
695
+ if (node.type === 'htmlComment') {
696
+ out.push(`<!--${node.value}-->`);
697
+ continue;
698
+ }
699
+ // htmlElement
700
+ const tag = node.tagName.toLowerCase();
701
+ const attrs = { ...node.attributes };
702
+ // Route media `src` attrs through the export's `ctx.images` URL
703
+ // map (which is actually a generic media map — see header comment).
704
+ // Without this, raw <video>/<audio> tags inserted by the recorder
705
+ // ship as relative paths that resolve against the export host, not
706
+ // the original ContentContainer.
707
+ if (
708
+ (tag === 'img' || tag === 'video' || tag === 'audio' || tag === 'source') &&
709
+ typeof attrs.src === 'string'
710
+ ) {
711
+ attrs.src = ctx?.images?.get(attrs.src) ?? attrs.src;
712
+ }
713
+ if ((tag === 'video' || tag === 'audio') && typeof attrs.poster === 'string') {
714
+ attrs.poster = ctx?.images?.get(attrs.poster) ?? attrs.poster;
715
+ }
716
+ const attrStr = Object.entries(attrs)
717
+ .map(([k, v]) => ` ${k}="${escapeAttr(v)}"`)
718
+ .join('');
719
+ if (node.selfClosing) {
720
+ out.push(`<${tag}${attrStr} />`);
721
+ } else {
722
+ out.push(`<${tag}${attrStr}>${htmlChildrenToHtml(node.children, ctx)}</${tag}>`);
723
+ }
724
+ }
725
+ return out.join('');
726
+ }
727
+
728
+ // ── Escaping ───────────────────────────────────────────────────────
729
+
730
+ function escapeHtml(s: string): string {
731
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
732
+ }
733
+
734
+ function escapeAttr(s: string): string {
735
+ return escapeHtml(s).replace(/"/g, '&quot;');
736
+ }