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