@bendyline/squisq-formats 1.3.1 → 1.4.1
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/README.md +180 -43
- package/dist/{chunk-33YRFXZZ.js → chunk-46FMDOWZ.js} +260 -14
- package/dist/chunk-46FMDOWZ.js.map +1 -0
- package/dist/{chunk-ERZ627GR.js → chunk-5LSSWZPU.js} +12 -27
- package/dist/chunk-5LSSWZPU.js.map +1 -0
- package/dist/chunk-6M7Z25LA.js +46 -0
- package/dist/chunk-6M7Z25LA.js.map +1 -0
- package/dist/{chunk-S3Y7H2BK.js → chunk-D7IFPWNZ.js} +95 -50
- package/dist/chunk-D7IFPWNZ.js.map +1 -0
- package/dist/chunk-DQAZR57U.js +33 -0
- package/dist/chunk-DQAZR57U.js.map +1 -0
- package/dist/chunk-EHLGMSTE.js +273 -0
- package/dist/chunk-EHLGMSTE.js.map +1 -0
- package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
- package/dist/chunk-MJGRI6XR.js.map +1 -0
- package/dist/chunk-PN52A5AA.js +32 -0
- package/dist/chunk-PN52A5AA.js.map +1 -0
- package/dist/chunk-RFAPOKHJ.js +123 -0
- package/dist/chunk-RFAPOKHJ.js.map +1 -0
- package/dist/chunk-RTS5XBZ7.js +526 -0
- package/dist/chunk-RTS5XBZ7.js.map +1 -0
- package/dist/{chunk-7DEUGIOO.js → chunk-SHLPSADL.js} +120 -2
- package/dist/chunk-SHLPSADL.js.map +1 -0
- package/dist/chunk-SSUPBUF5.js +15 -0
- package/dist/chunk-SSUPBUF5.js.map +1 -0
- package/dist/{chunk-UDS45KUJ.js → chunk-WWBIKRNP.js} +40 -80
- package/dist/chunk-WWBIKRNP.js.map +1 -0
- package/dist/{chunk-VN2KEOYB.js → chunk-YESS7VY3.js} +379 -87
- package/dist/chunk-YESS7VY3.js.map +1 -0
- package/dist/csv/index.d.ts +50 -0
- package/dist/csv/index.js +13 -0
- package/dist/csv/index.js.map +1 -0
- package/dist/docx/index.js +6 -3
- package/dist/epub/index.js +4 -2
- package/dist/html/index.d.ts +42 -2
- package/dist/html/index.js +9 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +47 -14
- package/dist/ooxml/index.d.ts +5 -1
- package/dist/ooxml/index.js +13 -9
- package/dist/pdf/index.js +2 -1
- package/dist/pptx/index.d.ts +36 -19
- package/dist/pptx/index.js +9 -2
- package/dist/registry/index.d.ts +167 -0
- package/dist/registry/index.js +17 -0
- package/dist/registry/index.js.map +1 -0
- package/dist/xlsx/index.d.ts +60 -29
- package/dist/xlsx/index.js +4 -1
- package/package.json +12 -2
- package/src/__tests__/convert.test.ts +186 -0
- package/src/__tests__/csvImport.test.ts +84 -0
- package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
- package/src/__tests__/formatRegistry.test.ts +174 -0
- package/src/__tests__/htmlImport.test.ts +57 -0
- package/src/__tests__/lossyWarnings.test.ts +105 -0
- package/src/__tests__/pdfImport.test.ts +95 -1
- package/src/__tests__/plainHtml.test.ts +31 -0
- package/src/__tests__/pptxExport.test.ts +138 -0
- package/src/__tests__/pptxImport.test.ts +115 -0
- package/src/__tests__/roundTripAssets.test.ts +50 -0
- package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
- package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
- package/src/__tests__/roundTripMatrix.test.ts +142 -0
- package/src/__tests__/sharedContainer.test.ts +41 -0
- package/src/__tests__/sharedImages.test.ts +61 -0
- package/src/__tests__/xlsxExport.test.ts +164 -0
- package/src/__tests__/xlsxImport.test.ts +80 -0
- package/src/csv/index.ts +188 -0
- package/src/docx/export.ts +27 -44
- package/src/docx/import.ts +7 -39
- package/src/epub/export.ts +10 -27
- package/src/html/imageUtils.ts +5 -19
- 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 +36 -5
- package/src/ooxml/index.ts +3 -0
- package/src/ooxml/namespaces.ts +13 -0
- package/src/pdf/export.ts +10 -5
- package/src/pdf/import.ts +146 -51
- package/src/pptx/export.ts +225 -85
- package/src/pptx/import.ts +273 -0
- package/src/pptx/index.ts +10 -28
- package/src/registry/convert.ts +318 -0
- package/src/registry/defaultFormats.ts +323 -0
- package/src/registry/errors.ts +46 -0
- package/src/registry/index.ts +38 -0
- package/src/registry/registry.ts +48 -0
- package/src/registry/types.ts +107 -0
- package/src/shared/container.ts +28 -0
- package/src/shared/images.ts +44 -0
- package/src/shared/inlineRuns.ts +99 -0
- package/src/shared/text.ts +41 -0
- package/src/xlsx/export.ts +253 -0
- package/src/xlsx/import.ts +159 -0
- package/src/xlsx/index.ts +18 -61
- 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-A3FHLTY5.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/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,
|
|
@@ -49,8 +50,9 @@ import type {
|
|
|
49
50
|
import { openPackage, getPartXml, getPartBinary, getPartRelationships } from '../ooxml/reader.js';
|
|
50
51
|
import type { OoxmlPackage, Relationship } from '../ooxml/types.js';
|
|
51
52
|
import { NS_WML, NS_R } from '../ooxml/namespaces.js';
|
|
52
|
-
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
53
53
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
54
|
+
import { buildContainer } from '../shared/container.js';
|
|
55
|
+
import { extToMime } from '../shared/images.js';
|
|
54
56
|
import {
|
|
55
57
|
HEADING_STYLE_MAP,
|
|
56
58
|
QUOTE_STYLE_IDS,
|
|
@@ -140,35 +142,15 @@ export async function docxToContainer(
|
|
|
140
142
|
const ctx = await buildImportContext(pkg, { ...options, extractImages: true });
|
|
141
143
|
|
|
142
144
|
const documentXml = await getPartXml(pkg, 'word/document.xml');
|
|
143
|
-
if (!documentXml)
|
|
144
|
-
const container = new MemoryContentContainer();
|
|
145
|
-
await container.writeDocument('');
|
|
146
|
-
return container;
|
|
147
|
-
}
|
|
145
|
+
if (!documentXml) return buildContainer('', []);
|
|
148
146
|
|
|
149
147
|
const body = getFirstElement(documentXml, 'body');
|
|
150
|
-
if (!body)
|
|
151
|
-
const container = new MemoryContentContainer();
|
|
152
|
-
await container.writeDocument('');
|
|
153
|
-
return container;
|
|
154
|
-
}
|
|
148
|
+
if (!body) return buildContainer('', []);
|
|
155
149
|
|
|
156
150
|
const blocks = await convertBody(body, ctx);
|
|
157
151
|
const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
|
|
158
152
|
|
|
159
|
-
|
|
160
|
-
const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
|
|
161
|
-
const markdown = stringifyMarkdown(markdownDoc);
|
|
162
|
-
|
|
163
|
-
// Build container with markdown + images
|
|
164
|
-
const container = new MemoryContentContainer();
|
|
165
|
-
await container.writeDocument(markdown);
|
|
166
|
-
|
|
167
|
-
for (const [path, { data: imageData, mimeType }] of ctx.extractedImages) {
|
|
168
|
-
await container.writeFile(path, new Uint8Array(imageData), mimeType);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return container;
|
|
153
|
+
return buildContainer(stringifyMarkdown(markdownDoc), ctx.extractedImages);
|
|
172
154
|
}
|
|
173
155
|
|
|
174
156
|
// ============================================
|
|
@@ -631,7 +613,7 @@ async function extractImage(el: Element, ctx: ImportContext): Promise<MarkdownIm
|
|
|
631
613
|
// Determine extension and MIME type
|
|
632
614
|
const dot = target.lastIndexOf('.');
|
|
633
615
|
const ext = dot !== -1 ? target.slice(dot).toLowerCase() : '.png';
|
|
634
|
-
const mimeType =
|
|
616
|
+
const mimeType = extToMime(ext);
|
|
635
617
|
|
|
636
618
|
// Generate a unique image path
|
|
637
619
|
ctx.imageCounter++;
|
|
@@ -651,20 +633,6 @@ async function extractImage(el: Element, ctx: ImportContext): Promise<MarkdownIm
|
|
|
651
633
|
};
|
|
652
634
|
}
|
|
653
635
|
|
|
654
|
-
const IMAGE_MIME_MAP: Record<string, string> = {
|
|
655
|
-
'.png': 'image/png',
|
|
656
|
-
'.jpg': 'image/jpeg',
|
|
657
|
-
'.jpeg': 'image/jpeg',
|
|
658
|
-
'.gif': 'image/gif',
|
|
659
|
-
'.bmp': 'image/bmp',
|
|
660
|
-
'.tiff': 'image/tiff',
|
|
661
|
-
'.tif': 'image/tiff',
|
|
662
|
-
'.svg': 'image/svg+xml',
|
|
663
|
-
'.webp': 'image/webp',
|
|
664
|
-
'.emf': 'image/emf',
|
|
665
|
-
'.wmf': 'image/wmf',
|
|
666
|
-
};
|
|
667
|
-
|
|
668
636
|
/** Recursively find the first descendant element with the given local name. */
|
|
669
637
|
function findDescendant(el: Element, localName: string): Element | null {
|
|
670
638
|
for (const child of Array.from(el.children)) {
|
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};
|
package/src/html/imageUtils.ts
CHANGED
|
@@ -5,31 +5,17 @@
|
|
|
5
5
|
* and inferring MIME types from filenames.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const MIME_MAP: Record<string, string> = {
|
|
10
|
-
jpg: 'image/jpeg',
|
|
11
|
-
jpeg: 'image/jpeg',
|
|
12
|
-
png: 'image/png',
|
|
13
|
-
gif: 'image/gif',
|
|
14
|
-
webp: 'image/webp',
|
|
15
|
-
svg: 'image/svg+xml',
|
|
16
|
-
ico: 'image/x-icon',
|
|
17
|
-
bmp: 'image/bmp',
|
|
18
|
-
avif: 'image/avif',
|
|
19
|
-
mp3: 'audio/mpeg',
|
|
20
|
-
wav: 'audio/wav',
|
|
21
|
-
ogg: 'audio/ogg',
|
|
22
|
-
mp4: 'video/mp4',
|
|
23
|
-
webm: 'video/webm',
|
|
24
|
-
};
|
|
8
|
+
import { extToMime } from '../shared/images.js';
|
|
25
9
|
|
|
26
10
|
/**
|
|
27
11
|
* Infer a MIME type from a filename's extension.
|
|
28
12
|
* Returns 'application/octet-stream' for unknown types.
|
|
13
|
+
*
|
|
14
|
+
* Thin wrapper over the shared {@link extToMime} map so html export, docx
|
|
15
|
+
* import, and pptx import all agree on extension → MIME.
|
|
29
16
|
*/
|
|
30
17
|
export function inferMimeType(filename: string): string {
|
|
31
|
-
|
|
32
|
-
return MIME_MAP[ext] ?? 'application/octet-stream';
|
|
18
|
+
return extToMime(filename.split('.').pop() ?? '');
|
|
33
19
|
}
|
|
34
20
|
|
|
35
21
|
/**
|
|
@@ -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';
|
package/src/html/plainHtml.ts
CHANGED
|
@@ -14,9 +14,18 @@
|
|
|
14
14
|
* blob URLs (live preview) or data URIs (single-file export).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import
|
|
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';
|
|
18
26
|
import type { Theme } from '@bendyline/squisq/schemas';
|
|
19
|
-
import { resolveFontFamily, buildGoogleFontsUrl
|
|
27
|
+
import { resolveFontFamily, buildGoogleFontsUrl } from '@bendyline/squisq/schemas';
|
|
28
|
+
import { resolveThemeForDoc } from '@bendyline/squisq/doc';
|
|
20
29
|
|
|
21
30
|
// ── Public Types ───────────────────────────────────────────────────
|
|
22
31
|
|
|
@@ -71,6 +80,11 @@ export interface PlainHtmlExportOptions {
|
|
|
71
80
|
* option is not provided.
|
|
72
81
|
*/
|
|
73
82
|
iconsCss?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Raw HTML policy. Defaults to `sanitize`, which removes unsafe tags,
|
|
85
|
+
* event handlers, and executable URL schemes before emitting HTML.
|
|
86
|
+
*/
|
|
87
|
+
htmlPolicy?: HtmlPolicy;
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
/**
|
|
@@ -81,6 +95,7 @@ export interface PlainHtmlExportOptions {
|
|
|
81
95
|
interface RenderCtx {
|
|
82
96
|
images?: Map<string, string>;
|
|
83
97
|
links?: Map<string, string>;
|
|
98
|
+
htmlPolicy: HtmlPolicy;
|
|
84
99
|
}
|
|
85
100
|
|
|
86
101
|
// ── Public API ─────────────────────────────────────────────────────
|
|
@@ -97,19 +112,20 @@ export function markdownDocToPlainHtml(
|
|
|
97
112
|
doc: MarkdownDocument,
|
|
98
113
|
options: PlainHtmlExportOptions = {},
|
|
99
114
|
): string {
|
|
100
|
-
const { title = 'Document', images, links, themeId, iconsCss } = options;
|
|
115
|
+
const { title = 'Document', images, links, themeId, iconsCss, htmlPolicy = 'sanitize' } = options;
|
|
101
116
|
// Fall back chain for theme: explicit `theme` → explicit `themeId`
|
|
102
117
|
// option → doc frontmatter `themeId`. Hosts whose export dialog
|
|
103
118
|
// tracks themes by id can pass `themeId` straight through; authored
|
|
104
119
|
// docs with `themeId: warm-earth` in frontmatter get styled
|
|
105
120
|
// automatically when neither is supplied.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
121
|
+
// Resolution is doc-scoped via `resolveThemeForDoc`, so an inline
|
|
122
|
+
// `squisq-custom-themes` id resolves here with no global registration —
|
|
123
|
+
// and `readFrontmatterThemeId` picks up the editor's canonical
|
|
124
|
+
// `squisq-theme` key. Stays undefined when nothing selects a theme so
|
|
125
|
+
// un-themed exports render unstyled (unchanged behavior).
|
|
126
|
+
const resolveId = themeId ?? readFrontmatterThemeId(doc.frontmatter);
|
|
127
|
+
const theme = options.theme ?? (resolveId ? resolveThemeForDoc(doc, resolveId) : undefined);
|
|
128
|
+
const ctx: RenderCtx = { images, links, htmlPolicy };
|
|
113
129
|
const body = renderTopLevel(doc.children, ctx);
|
|
114
130
|
const fontsLink = theme ? renderFontsLink(theme) : '';
|
|
115
131
|
// Resolve how to load FontAwesome — only when the doc actually uses
|
|
@@ -258,7 +274,9 @@ interface FeaturedImage {
|
|
|
258
274
|
|
|
259
275
|
function renderFeatureImage(img: FeaturedImage, ctx: RenderCtx | undefined): string {
|
|
260
276
|
const resolved = ctx?.images?.get(img.src) ?? img.src;
|
|
261
|
-
const
|
|
277
|
+
const safeSrc = sanitizeUrl(resolved, 'media');
|
|
278
|
+
if (!safeSrc) return '<div class="squisq-feature__media squisq-feature__media--empty"></div>';
|
|
279
|
+
const attrs = [`src="${escapeAttr(safeSrc)}"`, `alt="${escapeAttr(img.alt)}"`];
|
|
262
280
|
// Emit `width` / `height` attributes only when the source HTML had
|
|
263
281
|
// them. The CSS rules then know to honor those values rather than
|
|
264
282
|
// stretching the image to fill the column.
|
|
@@ -604,12 +622,16 @@ function nodeToHtml(node: MarkdownNode | undefined | null, ctx?: RenderCtx): str
|
|
|
604
622
|
case 'link': {
|
|
605
623
|
const original = node.url ?? '';
|
|
606
624
|
const rewritten = ctx?.links?.get(original) ?? original;
|
|
607
|
-
|
|
625
|
+
const safeHref = sanitizeUrl(rewritten, 'link');
|
|
626
|
+
if (!safeHref) return childrenToHtml(node, ctx);
|
|
627
|
+
return `<a href="${escapeAttr(safeHref)}">${childrenToHtml(node, ctx)}</a>`;
|
|
608
628
|
}
|
|
609
629
|
case 'image': {
|
|
610
630
|
const original = node.url ?? '';
|
|
611
631
|
const resolved = ctx?.images?.get(original) ?? original;
|
|
612
|
-
|
|
632
|
+
const safeSrc = sanitizeUrl(resolved, 'media');
|
|
633
|
+
if (!safeSrc) return escapeHtml(node.alt ?? '');
|
|
634
|
+
return `<img src="${escapeAttr(safeSrc)}" alt="${escapeAttr(node.alt ?? '')}" />`;
|
|
613
635
|
}
|
|
614
636
|
case 'thematicBreak':
|
|
615
637
|
return '<hr />';
|
|
@@ -626,11 +648,12 @@ function nodeToHtml(node: MarkdownNode | undefined | null, ctx?: RenderCtx): str
|
|
|
626
648
|
}
|
|
627
649
|
case 'htmlBlock':
|
|
628
650
|
case 'htmlInline':
|
|
651
|
+
if (resolveHtmlPolicy(ctx) === 'strip') return '';
|
|
629
652
|
// Resized images and other authored HTML survive the round-trip
|
|
630
653
|
// as parsed `htmlChildren` — rewriting `<img src>` through the
|
|
631
654
|
// image map keeps the preview consistent with the markdown-image
|
|
632
655
|
// path. Other tags pass through unmodified.
|
|
633
|
-
return htmlChildrenToHtml(node.htmlChildren, ctx);
|
|
656
|
+
return htmlChildrenToHtml(resolveHtmlNodes(node.htmlChildren, ctx), ctx);
|
|
634
657
|
default: {
|
|
635
658
|
// Unknown / unhandled node — recurse into children if any so we
|
|
636
659
|
// don't drop content (e.g. directives, footnotes).
|
|
@@ -682,18 +705,28 @@ function tableToHtml(
|
|
|
682
705
|
return parts.join('');
|
|
683
706
|
}
|
|
684
707
|
|
|
708
|
+
function resolveHtmlPolicy(ctx: RenderCtx | undefined): HtmlPolicy {
|
|
709
|
+
return ctx?.htmlPolicy ?? 'sanitize';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function resolveHtmlNodes(nodes: HtmlNode[] | undefined, ctx: RenderCtx | undefined): HtmlNode[] {
|
|
713
|
+
if (!nodes || nodes.length === 0) return [];
|
|
714
|
+
return resolveHtmlPolicy(ctx) === 'trusted' ? nodes : sanitizeHtmlNodes(nodes);
|
|
715
|
+
}
|
|
716
|
+
|
|
685
717
|
function htmlChildrenToHtml(nodes: HtmlNode[] | undefined, ctx?: RenderCtx): string {
|
|
686
718
|
if (!nodes || nodes.length === 0) return '';
|
|
719
|
+
const trusted = resolveHtmlPolicy(ctx) === 'trusted';
|
|
687
720
|
const out: string[] = [];
|
|
688
721
|
for (const node of nodes) {
|
|
689
722
|
if (node.type === 'htmlText') {
|
|
690
723
|
// HtmlText already represents authored HTML — emit verbatim so
|
|
691
724
|
// entity references the user wrote (e.g. `&`) survive.
|
|
692
|
-
out.push(node.value);
|
|
725
|
+
out.push(trusted ? node.value : escapeHtml(node.value));
|
|
693
726
|
continue;
|
|
694
727
|
}
|
|
695
728
|
if (node.type === 'htmlComment') {
|
|
696
|
-
out.push(`<!--${node.value}-->`);
|
|
729
|
+
if (trusted) out.push(`<!--${node.value}-->`);
|
|
697
730
|
continue;
|
|
698
731
|
}
|
|
699
732
|
// htmlElement
|
|
@@ -713,6 +746,23 @@ function htmlChildrenToHtml(nodes: HtmlNode[] | undefined, ctx?: RenderCtx): str
|
|
|
713
746
|
if ((tag === 'video' || tag === 'audio') && typeof attrs.poster === 'string') {
|
|
714
747
|
attrs.poster = ctx?.images?.get(attrs.poster) ?? attrs.poster;
|
|
715
748
|
}
|
|
749
|
+
if (!trusted) {
|
|
750
|
+
if (typeof attrs.src === 'string') {
|
|
751
|
+
const safeSrc = sanitizeUrl(attrs.src, 'media');
|
|
752
|
+
if (!safeSrc) delete attrs.src;
|
|
753
|
+
else attrs.src = safeSrc;
|
|
754
|
+
}
|
|
755
|
+
if (typeof attrs.poster === 'string') {
|
|
756
|
+
const safePoster = sanitizeUrl(attrs.poster, 'media');
|
|
757
|
+
if (!safePoster) delete attrs.poster;
|
|
758
|
+
else attrs.poster = safePoster;
|
|
759
|
+
}
|
|
760
|
+
if (typeof attrs.href === 'string') {
|
|
761
|
+
const safeHref = sanitizeUrl(attrs.href, 'link');
|
|
762
|
+
if (!safeHref) delete attrs.href;
|
|
763
|
+
else attrs.href = safeHref;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
716
766
|
const attrStr = Object.entries(attrs)
|
|
717
767
|
.map(([k, v]) => ` ${k}="${escapeAttr(v)}"`)
|
|
718
768
|
.join('');
|