@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.
- package/dist/chunk-6M7Z25LA.js +46 -0
- package/dist/chunk-6M7Z25LA.js.map +1 -0
- package/dist/{chunk-S3Y7H2BK.js → chunk-APSIAEXL.js} +9 -8
- package/dist/chunk-APSIAEXL.js.map +1 -0
- package/dist/{chunk-UDS45KUJ.js → chunk-BJQIJ4S4.js} +36 -52
- package/dist/chunk-BJQIJ4S4.js.map +1 -0
- package/dist/{chunk-33YRFXZZ.js → chunk-CRTC6DEX.js} +259 -13
- package/dist/chunk-CRTC6DEX.js.map +1 -0
- package/dist/chunk-FMOIGA5T.js +201 -0
- package/dist/chunk-FMOIGA5T.js.map +1 -0
- package/dist/{chunk-VN2KEOYB.js → chunk-I2GFTEOV.js} +321 -91
- package/dist/chunk-I2GFTEOV.js.map +1 -0
- package/dist/chunk-JBNVE2GV.js +116 -0
- package/dist/chunk-JBNVE2GV.js.map +1 -0
- package/dist/chunk-L53YIGYS.js +141 -0
- package/dist/chunk-L53YIGYS.js.map +1 -0
- package/dist/{chunk-ERZ627GR.js → chunk-ON232HJR.js} +11 -26
- package/dist/chunk-ON232HJR.js.map +1 -0
- package/dist/chunk-PN52A5AA.js +32 -0
- package/dist/chunk-PN52A5AA.js.map +1 -0
- package/dist/{chunk-7DEUGIOO.js → chunk-QSE7EWE7.js} +12 -87
- package/dist/chunk-QSE7EWE7.js.map +1 -0
- package/dist/csv/index.d.ts +37 -0
- package/dist/csv/index.js +13 -0
- package/dist/csv/index.js.map +1 -0
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.js +2 -1
- package/dist/html/index.d.ts +39 -2
- package/dist/html/index.js +7 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +31 -13
- package/dist/ooxml/index.js +16 -16
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +20 -18
- package/dist/pptx/index.js +6 -2
- package/dist/xlsx/index.d.ts +20 -19
- package/dist/xlsx/index.js +2 -1
- package/package.json +7 -2
- package/src/__tests__/csvImport.test.ts +51 -0
- package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
- package/src/__tests__/htmlImport.test.ts +57 -0
- package/src/__tests__/plainHtml.test.ts +31 -0
- package/src/__tests__/pptxExport.test.ts +138 -0
- package/src/__tests__/pptxImport.test.ts +70 -0
- package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
- package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
- package/src/__tests__/roundTripMatrix.test.ts +136 -0
- package/src/__tests__/xlsxImport.test.ts +80 -0
- package/src/csv/index.ts +165 -0
- package/src/docx/export.ts +27 -44
- package/src/docx/import.ts +1 -2
- package/src/epub/export.ts +10 -27
- package/src/html/import.ts +297 -0
- package/src/html/index.ts +4 -0
- package/src/html/plainHtml.ts +66 -16
- package/src/index.ts +10 -3
- package/src/ooxml/namespaces.ts +3 -0
- package/src/pdf/export.ts +10 -5
- package/src/pdf/import.ts +1 -1
- package/src/pptx/export.ts +225 -85
- package/src/pptx/import.ts +156 -0
- package/src/pptx/index.ts +10 -28
- package/src/shared/inlineRuns.ts +99 -0
- package/src/shared/text.ts +41 -0
- package/src/xlsx/import.ts +159 -0
- package/src/xlsx/index.ts +11 -28
- package/dist/chunk-33YRFXZZ.js.map +0 -1
- package/dist/chunk-67KIJHV2.js +0 -21
- package/dist/chunk-67KIJHV2.js.map +0 -1
- package/dist/chunk-7DEUGIOO.js.map +0 -1
- package/dist/chunk-ERZ627GR.js.map +0 -1
- package/dist/chunk-S3Y7H2BK.js.map +0 -1
- package/dist/chunk-UDS45KUJ.js.map +0 -1
- package/dist/chunk-VN2KEOYB.js.map +0 -1
- package/dist/chunk-YN5HFCEW.js +0 -120
- package/dist/chunk-YN5HFCEW.js.map +0 -1
package/src/docx/export.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { Doc, Theme } from '@bendyline/squisq/schemas';
|
|
21
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/docx/import.ts
CHANGED
|
@@ -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
|
package/src/epub/export.ts
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
import JSZip from 'jszip';
|
|
25
25
|
import type { Doc, AudioSegment } from '@bendyline/squisq/schemas';
|
|
26
|
-
import {
|
|
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
|
-
|
|
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
|
|
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?:
|
|
781
|
+
function generateStylesheet(themeId: string | undefined, doc?: MarkdownDocument): string {
|
|
799
782
|
let themeVars = '';
|
|
800
783
|
if (themeId) {
|
|
801
|
-
const theme =
|
|
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';
|