@bendyline/squisq-formats 1.3.1 → 1.4.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 (76) hide show
  1. package/dist/chunk-6M7Z25LA.js +46 -0
  2. package/dist/chunk-6M7Z25LA.js.map +1 -0
  3. package/dist/{chunk-S3Y7H2BK.js → chunk-APSIAEXL.js} +9 -8
  4. package/dist/chunk-APSIAEXL.js.map +1 -0
  5. package/dist/{chunk-UDS45KUJ.js → chunk-BJQIJ4S4.js} +36 -52
  6. package/dist/chunk-BJQIJ4S4.js.map +1 -0
  7. package/dist/{chunk-33YRFXZZ.js → chunk-CRTC6DEX.js} +259 -13
  8. package/dist/chunk-CRTC6DEX.js.map +1 -0
  9. package/dist/chunk-FMOIGA5T.js +201 -0
  10. package/dist/chunk-FMOIGA5T.js.map +1 -0
  11. package/dist/{chunk-VN2KEOYB.js → chunk-I2GFTEOV.js} +321 -91
  12. package/dist/chunk-I2GFTEOV.js.map +1 -0
  13. package/dist/chunk-JBNVE2GV.js +116 -0
  14. package/dist/chunk-JBNVE2GV.js.map +1 -0
  15. package/dist/chunk-L53YIGYS.js +141 -0
  16. package/dist/chunk-L53YIGYS.js.map +1 -0
  17. package/dist/{chunk-ERZ627GR.js → chunk-ON232HJR.js} +11 -26
  18. package/dist/chunk-ON232HJR.js.map +1 -0
  19. package/dist/chunk-PN52A5AA.js +32 -0
  20. package/dist/chunk-PN52A5AA.js.map +1 -0
  21. package/dist/{chunk-7DEUGIOO.js → chunk-QSE7EWE7.js} +12 -87
  22. package/dist/chunk-QSE7EWE7.js.map +1 -0
  23. package/dist/csv/index.d.ts +37 -0
  24. package/dist/csv/index.js +13 -0
  25. package/dist/csv/index.js.map +1 -0
  26. package/dist/docx/index.js +5 -3
  27. package/dist/epub/index.js +2 -1
  28. package/dist/html/index.d.ts +39 -2
  29. package/dist/html/index.js +7 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.js +31 -13
  32. package/dist/ooxml/index.js +16 -16
  33. package/dist/pdf/index.js +1 -1
  34. package/dist/pptx/index.d.ts +20 -18
  35. package/dist/pptx/index.js +6 -2
  36. package/dist/xlsx/index.d.ts +20 -19
  37. package/dist/xlsx/index.js +2 -1
  38. package/package.json +7 -2
  39. package/src/__tests__/csvImport.test.ts +51 -0
  40. package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
  41. package/src/__tests__/htmlImport.test.ts +57 -0
  42. package/src/__tests__/plainHtml.test.ts +31 -0
  43. package/src/__tests__/pptxExport.test.ts +138 -0
  44. package/src/__tests__/pptxImport.test.ts +70 -0
  45. package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
  46. package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
  47. package/src/__tests__/roundTripMatrix.test.ts +136 -0
  48. package/src/__tests__/xlsxImport.test.ts +80 -0
  49. package/src/csv/index.ts +165 -0
  50. package/src/docx/export.ts +27 -44
  51. package/src/docx/import.ts +1 -2
  52. package/src/epub/export.ts +10 -27
  53. package/src/html/import.ts +297 -0
  54. package/src/html/index.ts +4 -0
  55. package/src/html/plainHtml.ts +66 -16
  56. package/src/index.ts +10 -3
  57. package/src/ooxml/namespaces.ts +3 -0
  58. package/src/pdf/export.ts +10 -5
  59. package/src/pdf/import.ts +1 -1
  60. package/src/pptx/export.ts +225 -85
  61. package/src/pptx/import.ts +156 -0
  62. package/src/pptx/index.ts +10 -28
  63. package/src/shared/inlineRuns.ts +99 -0
  64. package/src/shared/text.ts +41 -0
  65. package/src/xlsx/import.ts +159 -0
  66. package/src/xlsx/index.ts +11 -28
  67. package/dist/chunk-33YRFXZZ.js.map +0 -1
  68. package/dist/chunk-67KIJHV2.js +0 -21
  69. package/dist/chunk-67KIJHV2.js.map +0 -1
  70. package/dist/chunk-7DEUGIOO.js.map +0 -1
  71. package/dist/chunk-ERZ627GR.js.map +0 -1
  72. package/dist/chunk-S3Y7H2BK.js.map +0 -1
  73. package/dist/chunk-UDS45KUJ.js.map +0 -1
  74. package/dist/chunk-VN2KEOYB.js.map +0 -1
  75. package/dist/chunk-YN5HFCEW.js +0 -120
  76. package/dist/chunk-YN5HFCEW.js.map +0 -1
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import type { Doc, Theme } from '@bendyline/squisq/schemas';
21
- import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
22
- import { docToMarkdown } from '@bendyline/squisq/doc';
21
+ import { resolveFontFamily } from '@bendyline/squisq/schemas';
22
+ import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
23
23
  import type {
24
24
  MarkdownDocument,
25
25
  MarkdownBlockNode,
@@ -44,6 +44,12 @@ import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
44
44
 
45
45
  import { createPackage } from '../ooxml/writer.js';
46
46
  import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
47
+ import { stripHtmlTags } from '../shared/text.js';
48
+ import {
49
+ inlineNodesToRuns,
50
+ inlineNodeToRuns,
51
+ type InlineRunHandlers,
52
+ } from '../shared/inlineRuns.js';
47
53
  import {
48
54
  NS_WML,
49
55
  NS_R,
@@ -126,7 +132,7 @@ export async function markdownDocToDocx(
126
132
  options.themeId !== undefined
127
133
  ? options
128
134
  : { ...options, themeId: readFrontmatterThemeId(doc.frontmatter) };
129
- const ctx = new ExportContext(resolvedOptions);
135
+ const ctx = new ExportContext(resolvedOptions, doc);
130
136
  const bodyXml = convertBlocks(doc.children, ctx);
131
137
  return buildDocxPackage(bodyXml, ctx, resolvedOptions);
132
138
  }
@@ -206,7 +212,7 @@ class ExportContext {
206
212
 
207
213
  private nextDocPrId = 1;
208
214
 
209
- constructor(options: DocxExportOptions) {
215
+ constructor(options: DocxExportOptions, doc?: MarkdownDocument) {
210
216
  let themeFont: string | undefined;
211
217
  let themeTitleFont: string | undefined;
212
218
  let themeHeadingColor: string | undefined;
@@ -215,7 +221,7 @@ class ExportContext {
215
221
  let themeBackgroundColor: string | undefined;
216
222
 
217
223
  if (options.themeId) {
218
- const theme: Theme = resolveTheme(options.themeId);
224
+ const theme: Theme = resolveThemeForDoc(doc, options.themeId);
219
225
  // Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
220
226
  // "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
221
227
  // single font name — passing the whole stack is treated as a
@@ -631,46 +637,30 @@ interface InlineFormat {
631
637
  color?: string;
632
638
  }
633
639
 
640
+ /**
641
+ * DOCX leaf handlers for the shared run-based inline walker. The traversal
642
+ * (format threading) lives in `shared/inlineRuns.ts`; these emit WordprocessingML.
643
+ */
644
+ function docxRunHandlers(ctx: ExportContext): InlineRunHandlers {
645
+ return {
646
+ run: (text, format) => makeRun(text, format),
647
+ link: (node, format) => convertLink(node, ctx, format),
648
+ image: (node) => convertImage(node, ctx),
649
+ lineBreak: () => `<w:r><w:br/></w:r>`,
650
+ footnoteRef: (node) => convertFootnoteRef(node, ctx),
651
+ };
652
+ }
653
+
634
654
  function convertInlines(
635
655
  nodes: MarkdownInlineNode[],
636
656
  ctx: ExportContext,
637
657
  format: InlineFormat = {},
638
658
  ): string {
639
- const parts: string[] = [];
640
- for (const node of nodes) {
641
- parts.push(convertInline(node, ctx, format));
642
- }
643
- return parts.join('');
659
+ return inlineNodesToRuns(nodes, docxRunHandlers(ctx), format);
644
660
  }
645
661
 
646
662
  function convertInline(node: MarkdownInlineNode, ctx: ExportContext, format: InlineFormat): string {
647
- switch (node.type) {
648
- case 'text':
649
- return makeRun(node.value, format);
650
- case 'strong':
651
- return convertInlines(node.children, ctx, { ...format, bold: true });
652
- case 'emphasis':
653
- return convertInlines(node.children, ctx, { ...format, italic: true });
654
- case 'delete':
655
- return convertInlines(node.children, ctx, { ...format, strike: true });
656
- case 'inlineCode':
657
- return makeRun(node.value, { ...format, code: true });
658
- case 'link':
659
- return convertLink(node, ctx, format);
660
- case 'image':
661
- return convertImage(node, ctx);
662
- case 'break':
663
- return `<w:r><w:br/></w:r>`;
664
- case 'htmlInline':
665
- return makeRun(stripHtmlTags(node.rawHtml), format);
666
- case 'inlineMath':
667
- return makeRun(node.value, { ...format, code: true });
668
- case 'footnoteReference':
669
- return convertFootnoteRef(node, ctx);
670
- default:
671
- // linkReference, imageReference, textDirective — skip or emit plain
672
- return '';
673
- }
663
+ return inlineNodeToRuns(node, docxRunHandlers(ctx), format);
674
664
  }
675
665
 
676
666
  function makeRun(text: string, format: InlineFormat): string {
@@ -1273,10 +1263,3 @@ function buildFootnotesXml(ctx: ExportContext): string {
1273
1263
  // ============================================
1274
1264
  // Helpers
1275
1265
  // ============================================
1276
-
1277
- /**
1278
- * Strip HTML tags from a string, keeping only text content.
1279
- */
1280
- function stripHtmlTags(html: string): string {
1281
- return html.replace(/<[^>]*>/g, '');
1282
- }
@@ -21,6 +21,7 @@
21
21
 
22
22
  import type { Doc } from '@bendyline/squisq/schemas';
23
23
  import { markdownToDoc } from '@bendyline/squisq/doc';
24
+ import { stringifyMarkdown } from '@bendyline/squisq/markdown';
24
25
  import type {
25
26
  MarkdownDocument,
26
27
  MarkdownBlockNode,
@@ -156,8 +157,6 @@ export async function docxToContainer(
156
157
  const blocks = await convertBody(body, ctx);
157
158
  const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
158
159
 
159
- // Serialize to markdown
160
- const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
161
160
  const markdown = stringifyMarkdown(markdownDoc);
162
161
 
163
162
  // Build container with markdown + images
@@ -23,7 +23,8 @@
23
23
 
24
24
  import JSZip from 'jszip';
25
25
  import type { Doc, AudioSegment } from '@bendyline/squisq/schemas';
26
- import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
26
+ import { resolveFontFamily } from '@bendyline/squisq/schemas';
27
+ import { resolveThemeForDoc } from '@bendyline/squisq/doc';
27
28
  import type {
28
29
  MarkdownDocument,
29
30
  MarkdownBlockNode,
@@ -35,8 +36,10 @@ import type {
35
36
  MarkdownTableRow,
36
37
  MarkdownTableCell,
37
38
  } from '@bendyline/squisq/markdown';
39
+ import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
38
40
  import { escapeXml } from '../ooxml/xmlUtils.js';
39
41
  import { inferMimeType, extractFilename } from '../html/imageUtils.js';
42
+ import { extractPlainText } from '../shared/text.js';
40
43
 
41
44
  // ── Public API ────────────────────────────────────────────────────
42
45
 
@@ -119,8 +122,9 @@ export async function markdownDocToEpub(
119
122
  }
120
123
  }
121
124
 
122
- // Generate theme CSS
123
- const css = generateStylesheet(options.themeId);
125
+ // Generate theme CSS — honor an explicit themeId, else the doc's frontmatter
126
+ // theme (`squisq-theme` / legacy), mirroring the other export formats.
127
+ const css = generateStylesheet(options.themeId ?? readFrontmatterThemeId(doc.frontmatter), doc);
124
128
 
125
129
  // Build the ZIP
126
130
  const zip = new JSZip();
@@ -360,28 +364,7 @@ function splitIntoChapters(nodes: MarkdownBlockNode[]): Chapter[] {
360
364
  }
361
365
 
362
366
  function extractHeadingText(heading: MarkdownHeading): string {
363
- return heading.children.map(inlineToText).join('');
364
- }
365
-
366
- function inlineToText(node: MarkdownInlineNode): string {
367
- switch (node.type) {
368
- case 'text':
369
- return node.value;
370
- case 'emphasis':
371
- case 'strong':
372
- case 'delete':
373
- return node.children.map(inlineToText).join('');
374
- case 'inlineCode':
375
- return node.value;
376
- case 'link':
377
- return node.children.map(inlineToText).join('');
378
- case 'image':
379
- return node.alt ?? '';
380
- case 'break':
381
- return ' ';
382
- default:
383
- return '';
384
- }
367
+ return extractPlainText(heading.children);
385
368
  }
386
369
 
387
370
  // ── Image Collection ──────────────────────────────────────────────
@@ -795,10 +778,10 @@ ${navItems}
795
778
 
796
779
  // ── Stylesheet ────────────────────────────────────────────────────
797
780
 
798
- function generateStylesheet(themeId?: string): string {
781
+ function generateStylesheet(themeId: string | undefined, doc?: MarkdownDocument): string {
799
782
  let themeVars = '';
800
783
  if (themeId) {
801
- const theme = resolveTheme(themeId);
784
+ const theme = resolveThemeForDoc(doc, themeId);
802
785
  themeVars = `
803
786
  --epub-bg: ${theme.colors.background};
804
787
  --epub-text: ${theme.colors.text};
@@ -0,0 +1,297 @@
1
+ /**
2
+ * HTML Import Module — @bendyline/squisq-formats/html
3
+ *
4
+ * Converts an HTML document (or fragment) into a squisq `MarkdownDocument`.
5
+ * Built on the existing `parseHtmlToNodes` (parse5-backed) + `sanitizeHtmlNodes`
6
+ * from `@bendyline/squisq/markdown`, so no new dependency is introduced.
7
+ *
8
+ * The primary consumer is email: HTML mail bodies are hostile input, so the
9
+ * parse is sanitized by default (scripts/styles/event handlers/dangerous URLs
10
+ * stripped) before the tree is walked into markdown nodes.
11
+ *
12
+ * Two entry points mirror the docx/pdf importers:
13
+ * - `htmlToMarkdownDoc(data, options?)` → `MarkdownDocument`
14
+ * - `htmlToMarkdown(html, options?)` → markdown string (convenience for the
15
+ * string-in/string-out path email body conversion needs)
16
+ */
17
+
18
+ import {
19
+ type MarkdownBlockNode,
20
+ type MarkdownDocument,
21
+ type MarkdownInlineNode,
22
+ type MarkdownListItem,
23
+ type MarkdownTableCell,
24
+ type MarkdownTableRow,
25
+ type HtmlElement,
26
+ type HtmlNode,
27
+ parseHtmlToNodes,
28
+ sanitizeHtmlNodes,
29
+ stringifyMarkdown,
30
+ } from '@bendyline/squisq/markdown';
31
+
32
+ export interface HtmlImportOptions {
33
+ /**
34
+ * Strip scripts / styles / event handlers / dangerous URLs before walking
35
+ * the tree. Default `true` — HTML email is untrusted; only disable for
36
+ * trusted input where you want raw fidelity.
37
+ */
38
+ sanitize?: boolean;
39
+ }
40
+
41
+ // ── tag classification ──────────────────────────────────────────────
42
+
43
+ const HEADINGS: Record<string, 1 | 2 | 3 | 4 | 5 | 6> = {
44
+ h1: 1,
45
+ h2: 2,
46
+ h3: 3,
47
+ h4: 4,
48
+ h5: 5,
49
+ h6: 6,
50
+ };
51
+
52
+ // Containers with no semantic block meaning — unwrap and walk their children.
53
+ const TRANSPARENT_BLOCK = new Set([
54
+ 'div',
55
+ 'section',
56
+ 'article',
57
+ 'header',
58
+ 'footer',
59
+ 'main',
60
+ 'aside',
61
+ 'nav',
62
+ 'figure',
63
+ 'figcaption',
64
+ 'form',
65
+ 'fieldset',
66
+ 'body',
67
+ 'html',
68
+ 'center',
69
+ ]);
70
+
71
+ // Elements that carry no content we want (and whose text must not leak).
72
+ const DROP = new Set(['script', 'style', 'head', 'title', 'noscript', 'template', 'svg']);
73
+
74
+ const INLINE_STRONG = new Set(['strong', 'b']);
75
+ const INLINE_EM = new Set(['em', 'i']);
76
+ const INLINE_DEL = new Set(['del', 's', 'strike']);
77
+ const INLINE_TRANSPARENT = new Set(['span', 'font', 'abbr', 'mark', 'small', 'sub', 'sup', 'u']);
78
+
79
+ const isElement = (n: HtmlNode): n is HtmlElement => n.type === 'htmlElement';
80
+ const isText = (n: HtmlNode): n is { type: 'htmlText'; value: string } => n.type === 'htmlText';
81
+
82
+ const collapseWs = (s: string): string => s.replace(/\s+/g, ' ');
83
+
84
+ // ── inline conversion ───────────────────────────────────────────────
85
+
86
+ function inlinesFromNodes(nodes: HtmlNode[]): MarkdownInlineNode[] {
87
+ const out: MarkdownInlineNode[] = [];
88
+ for (const node of nodes) {
89
+ if (isText(node)) {
90
+ const value = collapseWs(node.value);
91
+ if (value) out.push({ type: 'text', value });
92
+ continue;
93
+ }
94
+ if (!isElement(node)) continue; // comment
95
+ const tag = node.tagName.toLowerCase();
96
+ if (DROP.has(tag)) continue;
97
+
98
+ if (tag === 'br') {
99
+ out.push({ type: 'break' });
100
+ } else if (INLINE_STRONG.has(tag)) {
101
+ out.push({ type: 'strong', children: inlinesFromNodes(node.children) });
102
+ } else if (INLINE_EM.has(tag)) {
103
+ out.push({ type: 'emphasis', children: inlinesFromNodes(node.children) });
104
+ } else if (INLINE_DEL.has(tag)) {
105
+ out.push({ type: 'delete', children: inlinesFromNodes(node.children) });
106
+ } else if (tag === 'code' || tag === 'kbd' || tag === 'samp' || tag === 'tt') {
107
+ out.push({ type: 'inlineCode', value: textContent(node) });
108
+ } else if (tag === 'a') {
109
+ const url = node.attributes.href ?? '';
110
+ const children = inlinesFromNodes(node.children);
111
+ out.push({
112
+ type: 'link',
113
+ url,
114
+ children: children.length > 0 ? children : [{ type: 'text', value: url }],
115
+ });
116
+ } else if (tag === 'img') {
117
+ const url = node.attributes.src ?? '';
118
+ const alt = node.attributes.alt;
119
+ if (url) out.push({ type: 'image', url, ...(alt ? { alt } : {}) });
120
+ } else {
121
+ // Unknown / transparent inline (span, font, …): flatten children.
122
+ out.push(...inlinesFromNodes(node.children));
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Flatten an element's descendant text (for code blocks / inline code). */
129
+ function textContent(node: HtmlNode): string {
130
+ if (isText(node)) return node.value;
131
+ if (isElement(node)) return node.children.map(textContent).join('');
132
+ return '';
133
+ }
134
+
135
+ const onlyWhitespace = (inlines: MarkdownInlineNode[]): boolean =>
136
+ inlines.every((n) => n.type === 'text' && n.value.trim() === '');
137
+
138
+ // ── block conversion ────────────────────────────────────────────────
139
+
140
+ function blocksFromNodes(nodes: HtmlNode[]): MarkdownBlockNode[] {
141
+ const out: MarkdownBlockNode[] = [];
142
+ let inlineBuffer: HtmlNode[] = [];
143
+
144
+ const flush = () => {
145
+ if (inlineBuffer.length === 0) return;
146
+ const inlines = inlinesFromNodes(inlineBuffer);
147
+ inlineBuffer = [];
148
+ if (inlines.length > 0 && !onlyWhitespace(inlines)) {
149
+ out.push({ type: 'paragraph', children: inlines });
150
+ }
151
+ };
152
+
153
+ for (const node of nodes) {
154
+ if (isText(node)) {
155
+ inlineBuffer.push(node);
156
+ continue;
157
+ }
158
+ if (!isElement(node)) continue;
159
+ const tag = node.tagName.toLowerCase();
160
+ if (DROP.has(tag)) continue;
161
+
162
+ const block = blockForElement(node, tag);
163
+ if (block === 'inline') {
164
+ inlineBuffer.push(node);
165
+ } else if (block) {
166
+ flush();
167
+ out.push(...block);
168
+ }
169
+ }
170
+ flush();
171
+ return out;
172
+ }
173
+
174
+ /** Returns the block node(s) for a block element, `'inline'` for inline ones. */
175
+ function blockForElement(node: HtmlElement, tag: string): MarkdownBlockNode[] | 'inline' | null {
176
+ if (tag in HEADINGS) {
177
+ const children = inlinesFromNodes(node.children);
178
+ return [{ type: 'heading', depth: HEADINGS[tag]!, children }];
179
+ }
180
+ if (tag === 'p') {
181
+ const children = inlinesFromNodes(node.children);
182
+ return children.length > 0 && !onlyWhitespace(children)
183
+ ? [{ type: 'paragraph', children }]
184
+ : [];
185
+ }
186
+ if (tag === 'br') return 'inline';
187
+ if (tag === 'hr') return [{ type: 'thematicBreak' }];
188
+ if (tag === 'blockquote') {
189
+ return [{ type: 'blockquote', children: blocksFromNodes(node.children) }];
190
+ }
191
+ if (tag === 'pre') {
192
+ return [{ type: 'code', value: stripTrailingNewline(textContent(node)) }];
193
+ }
194
+ if (tag === 'ul' || tag === 'ol') {
195
+ return [listFromElement(node, tag === 'ol')];
196
+ }
197
+ if (tag === 'table') {
198
+ const table = tableFromElement(node);
199
+ return table ? [table] : [];
200
+ }
201
+ if (TRANSPARENT_BLOCK.has(tag)) {
202
+ return blocksFromNodes(node.children);
203
+ }
204
+ if (
205
+ INLINE_STRONG.has(tag) ||
206
+ INLINE_EM.has(tag) ||
207
+ INLINE_DEL.has(tag) ||
208
+ INLINE_TRANSPARENT.has(tag) ||
209
+ tag === 'a' ||
210
+ tag === 'img' ||
211
+ tag === 'code' ||
212
+ tag === 'kbd' ||
213
+ tag === 'samp'
214
+ ) {
215
+ return 'inline';
216
+ }
217
+ // Unknown element: treat as a transparent container so its content survives.
218
+ return blocksFromNodes(node.children);
219
+ }
220
+
221
+ function listFromElement(node: HtmlElement, ordered: boolean): MarkdownBlockNode {
222
+ const items: MarkdownListItem[] = [];
223
+ for (const child of node.children) {
224
+ if (isElement(child) && child.tagName.toLowerCase() === 'li') {
225
+ const blocks = blocksFromNodes(child.children);
226
+ items.push({ type: 'listItem', children: blocks });
227
+ }
228
+ }
229
+ const startAttr = node.attributes.start;
230
+ const start = ordered && startAttr ? Number.parseInt(startAttr, 10) : undefined;
231
+ return {
232
+ type: 'list',
233
+ ordered,
234
+ ...(start !== undefined && Number.isFinite(start) ? { start } : {}),
235
+ children: items,
236
+ };
237
+ }
238
+
239
+ function tableFromElement(node: HtmlElement): MarkdownBlockNode | null {
240
+ const rows: MarkdownTableRow[] = [];
241
+ const collectRows = (n: HtmlElement) => {
242
+ for (const child of n.children) {
243
+ if (!isElement(child)) continue;
244
+ const t = child.tagName.toLowerCase();
245
+ if (t === 'tr') {
246
+ const cells: MarkdownTableCell[] = [];
247
+ for (const cell of child.children) {
248
+ if (
249
+ isElement(cell) &&
250
+ (cell.tagName.toLowerCase() === 'td' || cell.tagName.toLowerCase() === 'th')
251
+ ) {
252
+ cells.push({ type: 'tableCell', children: inlinesFromNodes(cell.children) });
253
+ }
254
+ }
255
+ if (cells.length > 0) rows.push({ type: 'tableRow', children: cells });
256
+ } else if (t === 'thead' || t === 'tbody' || t === 'tfoot') {
257
+ collectRows(child);
258
+ }
259
+ }
260
+ };
261
+ collectRows(node);
262
+ if (rows.length === 0) return null;
263
+ return { type: 'table', children: rows };
264
+ }
265
+
266
+ const stripTrailingNewline = (s: string): string => s.replace(/\n+$/, '');
267
+
268
+ // ── public API ──────────────────────────────────────────────────────
269
+
270
+ function toHtmlString(data: ArrayBuffer | Uint8Array | string): string {
271
+ if (typeof data === 'string') return data;
272
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
273
+ return new TextDecoder('utf-8').decode(bytes);
274
+ }
275
+
276
+ /** Parse HTML into a squisq `MarkdownDocument`. */
277
+ export function htmlToMarkdownDocSync(
278
+ html: string,
279
+ options: HtmlImportOptions = {},
280
+ ): MarkdownDocument {
281
+ let nodes = parseHtmlToNodes(html);
282
+ if (options.sanitize !== false) nodes = sanitizeHtmlNodes(nodes);
283
+ return { type: 'document', children: blocksFromNodes(nodes) };
284
+ }
285
+
286
+ /** Async, ArrayBuffer-accepting entry mirroring docx/pdf importers. */
287
+ export async function htmlToMarkdownDoc(
288
+ data: ArrayBuffer | Uint8Array | string,
289
+ options: HtmlImportOptions = {},
290
+ ): Promise<MarkdownDocument> {
291
+ return htmlToMarkdownDocSync(toHtmlString(data), options);
292
+ }
293
+
294
+ /** Convenience string→string conversion (used for email HTML bodies). */
295
+ export function htmlToMarkdown(html: string, options: HtmlImportOptions = {}): string {
296
+ return stringifyMarkdown(htmlToMarkdownDocSync(html, options));
297
+ }
package/src/html/index.ts CHANGED
@@ -195,3 +195,7 @@ export { markdownDocsToPlainHtmlBundle, collectLinkRefs } from './plainHtmlBundl
195
195
  export type { PlainHtmlBundleOptions } from './plainHtmlBundle.js';
196
196
  export { markdownDocsToHtmlBundle } from './docsHtmlBundle.js';
197
197
  export type { HtmlBundleOptions } from './docsHtmlBundle.js';
198
+
199
+ // ── Import (HTML → MarkdownDocument) ────────────────────────────────
200
+ export { htmlToMarkdown, htmlToMarkdownDoc, htmlToMarkdownDocSync } from './import.js';
201
+ export type { HtmlImportOptions } from './import.js';