page-markdown 0.1.4 → 0.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.
- package/CHANGELOG.md +149 -0
- package/README.md +139 -20
- package/dist/types/clean.d.ts +23 -0
- package/dist/types/convert.d.ts +19 -0
- package/dist/types/extract.d.ts +42 -0
- package/dist/types/facts.d.ts +15 -0
- package/dist/types/hidden.d.ts +14 -0
- package/dist/types/index.d.ts +107 -0
- package/dist/types/metadata.d.ts +18 -0
- package/dist/types/refine.d.ts +31 -0
- package/dist/types/sanitize.d.ts +28 -0
- package/dist/types/standardize.d.ts +16 -0
- package/dist/types/types.d.ts +167 -0
- package/package.json +17 -5
- package/src/clean.js +21 -9
- package/src/convert.js +142 -27
- package/src/extract.js +211 -70
- package/src/facts.js +102 -96
- package/src/hidden.js +46 -45
- package/src/index.js +200 -162
- package/src/metadata.js +79 -78
- package/src/refine.js +115 -104
- package/src/sanitize.js +217 -193
- package/src/standardize.js +150 -17
- package/src/types.js +122 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The text of serialized HTML: tags to spaces, the entities serializers write decoded. */
|
|
2
|
+
export function htmlText(html: any): string;
|
|
3
|
+
/**
|
|
4
|
+
* Many sites state their summary as both the meta description and the first
|
|
5
|
+
* paragraph. Extractors can drop that paragraph when it sits in a section
|
|
6
|
+
* without a heading (MDN). If the description matches a paragraph in the page
|
|
7
|
+
* that the extracted HTML lacks, put that paragraph first.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} html extracted HTML
|
|
10
|
+
* @param {Document} doc standardized clone
|
|
11
|
+
* @param {string} description meta description
|
|
12
|
+
* @returns {{ html: string, recovered: boolean }}
|
|
13
|
+
*/
|
|
14
|
+
export function recoverLeadParagraph(html: string, doc: Document, description: string): {
|
|
15
|
+
html: string;
|
|
16
|
+
recovered: boolean;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The heading for the capture: the page's own H1 when the document title or
|
|
20
|
+
* og:title contains it (and it is not just the site name); otherwise og:title;
|
|
21
|
+
* otherwise the title without a trailing site-name segment.
|
|
22
|
+
*
|
|
23
|
+
* @param {Document} doc standardized clone
|
|
24
|
+
* @param {{ documentTitle?: string, title?: string, siteName?: string, extractedTitle?: string }} [meta]
|
|
25
|
+
*/
|
|
26
|
+
export function chooseHeadline(doc: Document, meta?: {
|
|
27
|
+
documentTitle?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
siteName?: string;
|
|
30
|
+
extractedTitle?: string;
|
|
31
|
+
}): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @param {any} node */
|
|
2
|
+
export function isAdvertisementElement(node: any): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Creates an empty HTML document unconnected to the page: no scripts run and
|
|
5
|
+
* no custom elements upgrade in it.
|
|
6
|
+
* @param {Document} doc
|
|
7
|
+
*/
|
|
8
|
+
export function createInertDocument(doc: Document): Document;
|
|
9
|
+
/**
|
|
10
|
+
* Clones `root` (a Document or Element) into a new inert document.
|
|
11
|
+
* Open shadow roots replace their host's light children (native slots supply
|
|
12
|
+
* assigned children). Limits apply only to shadow content, per host in the
|
|
13
|
+
* page's own tree, so slotted page content is not charged: docs sites put a
|
|
14
|
+
* whole article in one host's slot. A host over the limit keeps what fits and
|
|
15
|
+
* is counted as an omission.
|
|
16
|
+
*
|
|
17
|
+
* @param {Document|Element} root
|
|
18
|
+
* @param {import("./types.js").SanitizeOptions} [options]
|
|
19
|
+
* @returns {{ document: Document, omissions: { shadowHosts: number } }}
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeToInertDocument(root: Document | Element, options?: import("./types.js").SanitizeOptions): {
|
|
22
|
+
document: Document;
|
|
23
|
+
omissions: {
|
|
24
|
+
shadowHosts: number;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/** @type {Readonly<Required<import("./types.js").SanitizeOptions>>} */
|
|
28
|
+
export const DEFAULT_SANITIZE_OPTIONS: Readonly<Required<import("./types.js").SanitizeOptions>>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {Document} doc the inert clone (mutated in place)
|
|
3
|
+
* @param {{ url?: string }} [options]
|
|
4
|
+
*/
|
|
5
|
+
export function standardizeDocument(doc: Document, options?: {
|
|
6
|
+
url?: string;
|
|
7
|
+
}): {
|
|
8
|
+
lazyImagesResolved: number;
|
|
9
|
+
inlineImagesDropped: number;
|
|
10
|
+
scriptLinksUnwrapped: number;
|
|
11
|
+
emptyLinksRemoved: number;
|
|
12
|
+
preWrapped: number;
|
|
13
|
+
headingWidgetsRemoved: number;
|
|
14
|
+
headingsUnwrapped: number;
|
|
15
|
+
omittedTabs: string[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/// <reference lib="dom" preserve="true" />
|
|
2
|
+
export type OutputMode = "full" | "no_links" | "text_only";
|
|
3
|
+
export type SanitizeOptions = {
|
|
4
|
+
stripScripts?: boolean | undefined;
|
|
5
|
+
stripStyles?: boolean | undefined;
|
|
6
|
+
stripNoscript?: boolean | undefined;
|
|
7
|
+
stripMeta?: boolean | undefined;
|
|
8
|
+
stripLinkTags?: boolean | undefined;
|
|
9
|
+
stripAds?: boolean | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* app-specific overlays or widgets to drop, e.g. "[data-app-overlay]"
|
|
12
|
+
*/
|
|
13
|
+
extraStripSelectors?: readonly string[] | undefined;
|
|
14
|
+
shadowNodeLimit?: number | undefined;
|
|
15
|
+
shadowCharLimit?: number | undefined;
|
|
16
|
+
shadowDepthLimit?: number | undefined;
|
|
17
|
+
};
|
|
18
|
+
export type ReadabilityOptions = NonNullable<ConstructorParameters<typeof import("@mozilla/readability").Readability<string>>[1]>;
|
|
19
|
+
export type Extraction = import("./extract.js").Extraction;
|
|
20
|
+
export type Extractor = import("./extract.js").Extractor;
|
|
21
|
+
export type CollectedMetadata = ReturnType<typeof import("./metadata.js").collectMetadata>;
|
|
22
|
+
export type ExtractedMetadata = {
|
|
23
|
+
/**
|
|
24
|
+
* the heading chosen for the capture
|
|
25
|
+
*/
|
|
26
|
+
headline?: string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* the title the extractor reported
|
|
29
|
+
*/
|
|
30
|
+
extractedTitle?: string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* the byline the extractor reported
|
|
33
|
+
*/
|
|
34
|
+
byline?: string | undefined;
|
|
35
|
+
};
|
|
36
|
+
export type PageMarkdownMetadata = CollectedMetadata & ExtractedMetadata;
|
|
37
|
+
export type PageMarkdownExtraction = {
|
|
38
|
+
method: Extraction["method"];
|
|
39
|
+
/**
|
|
40
|
+
* why extraction fell back to the full page or the main content
|
|
41
|
+
*/
|
|
42
|
+
fallbackReason?: string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* a dropped lead paragraph was restored
|
|
45
|
+
*/
|
|
46
|
+
leadRecovered?: true | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* how many facts `pageFacts` appended
|
|
49
|
+
*/
|
|
50
|
+
pageFacts?: number | undefined;
|
|
51
|
+
};
|
|
52
|
+
export type HiddenRunKind = "tag" | "variation_selector" | "invisible_operator" | "reserved_tag_plane";
|
|
53
|
+
/**
|
|
54
|
+
* A run as text-sanctifier's findHiddenRuns reports it.
|
|
55
|
+
*/
|
|
56
|
+
export type HiddenRun = {
|
|
57
|
+
kind: HiddenRunKind;
|
|
58
|
+
/**
|
|
59
|
+
* UTF-16 index of the first character
|
|
60
|
+
*/
|
|
61
|
+
offset: number;
|
|
62
|
+
/**
|
|
63
|
+
* UTF-16 index just past the last character
|
|
64
|
+
*/
|
|
65
|
+
end: number;
|
|
66
|
+
/**
|
|
67
|
+
* code points in the run
|
|
68
|
+
*/
|
|
69
|
+
count: number;
|
|
70
|
+
/**
|
|
71
|
+
* 1-based line of the first character
|
|
72
|
+
*/
|
|
73
|
+
line: number;
|
|
74
|
+
/**
|
|
75
|
+
* 1-based column of the first character, in UTF-16 units
|
|
76
|
+
*/
|
|
77
|
+
column: number;
|
|
78
|
+
/**
|
|
79
|
+
* tag runs only: the ASCII the tags spell
|
|
80
|
+
*/
|
|
81
|
+
decoded?: string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* tag runs only: true when `decoded` stops short of the run
|
|
84
|
+
*/
|
|
85
|
+
decodedTruncated?: boolean | undefined;
|
|
86
|
+
};
|
|
87
|
+
export type HiddenTextOmission = {
|
|
88
|
+
kind: `hidden_${HiddenRunKind}`;
|
|
89
|
+
/**
|
|
90
|
+
* runs removed from `markdown`
|
|
91
|
+
*/
|
|
92
|
+
count: number;
|
|
93
|
+
/**
|
|
94
|
+
* code points in those runs
|
|
95
|
+
*/
|
|
96
|
+
chars: number;
|
|
97
|
+
note: string;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Check `kind` before reading `chars`: only hidden-text omissions have it.
|
|
101
|
+
*/
|
|
102
|
+
export type PageMarkdownOmission = {
|
|
103
|
+
kind: "shadow_content";
|
|
104
|
+
count: number;
|
|
105
|
+
note: string;
|
|
106
|
+
} | {
|
|
107
|
+
kind: "json_line";
|
|
108
|
+
count: number;
|
|
109
|
+
note: string;
|
|
110
|
+
} | {
|
|
111
|
+
kind: "inactive_tabs";
|
|
112
|
+
count: number;
|
|
113
|
+
note: string;
|
|
114
|
+
} | {
|
|
115
|
+
kind: "comments";
|
|
116
|
+
count: number;
|
|
117
|
+
note: string;
|
|
118
|
+
} | HiddenTextOmission;
|
|
119
|
+
export type StandardizationStats = ReturnType<typeof import("./standardize.js").standardizeDocument>;
|
|
120
|
+
export type PageMarkdownStats = {
|
|
121
|
+
mode: OutputMode;
|
|
122
|
+
extractedHtmlChars: number;
|
|
123
|
+
convertedChars: number;
|
|
124
|
+
markdownChars: number;
|
|
125
|
+
/**
|
|
126
|
+
* the page's text at capture time: a capture is a
|
|
127
|
+
* snapshot of the DOM, so a caller can re-capture until this number settles
|
|
128
|
+
*/
|
|
129
|
+
pageTextChars: number;
|
|
130
|
+
standardization: StandardizationStats;
|
|
131
|
+
};
|
|
132
|
+
export type PageMarkdownResult = {
|
|
133
|
+
/**
|
|
134
|
+
* the package version that made this result
|
|
135
|
+
*/
|
|
136
|
+
version: string;
|
|
137
|
+
markdown: string;
|
|
138
|
+
metadata: PageMarkdownMetadata;
|
|
139
|
+
extraction: PageMarkdownExtraction;
|
|
140
|
+
omissions: PageMarkdownOmission[];
|
|
141
|
+
warnings: string[];
|
|
142
|
+
stats: PageMarkdownStats;
|
|
143
|
+
/**
|
|
144
|
+
* present only with `includeFullPage`
|
|
145
|
+
*/
|
|
146
|
+
fullPageMarkdown?: string | undefined;
|
|
147
|
+
};
|
|
148
|
+
export type PageFact = {
|
|
149
|
+
label: string;
|
|
150
|
+
value: string;
|
|
151
|
+
};
|
|
152
|
+
export type TurndownFilter = string | string[] | ((node: HTMLElement, options: Record<string, unknown>) => boolean);
|
|
153
|
+
/**
|
|
154
|
+
* Turndown's public methods; Turndown ships no types of its own.
|
|
155
|
+
*/
|
|
156
|
+
export type TurndownInstance = {
|
|
157
|
+
turndown: (input: string | Node) => string;
|
|
158
|
+
use: (plugin: TurndownPlugin | TurndownPlugin[]) => TurndownInstance;
|
|
159
|
+
addRule: (key: string, rule: {
|
|
160
|
+
filter: TurndownFilter;
|
|
161
|
+
replacement: (content: string, node: Node, options: Record<string, unknown>) => string;
|
|
162
|
+
}) => TurndownInstance;
|
|
163
|
+
keep: (filter: TurndownFilter) => TurndownInstance;
|
|
164
|
+
remove: (filter: TurndownFilter) => TurndownInstance;
|
|
165
|
+
escape: (text: string) => string;
|
|
166
|
+
};
|
|
167
|
+
export type TurndownPlugin = (service: TurndownInstance) => void;
|
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "page-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Faithful page-to-Markdown for humans and agents: sanitize, extract the readable article, convert with GFM, and clean without losing content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".":
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/types/index.d.ts",
|
|
9
|
+
"default": "./src/index.js"
|
|
10
|
+
}
|
|
8
11
|
},
|
|
12
|
+
"types": "./dist/types/index.d.ts",
|
|
9
13
|
"files": [
|
|
10
14
|
"src",
|
|
15
|
+
"dist/types",
|
|
11
16
|
"LICENSE",
|
|
12
17
|
"ADDITIONAL_TERMS.md",
|
|
13
18
|
"THIRD_PARTY_NOTICES.md",
|
|
@@ -22,9 +27,15 @@
|
|
|
22
27
|
},
|
|
23
28
|
"scripts": {
|
|
24
29
|
"test": "node --test \"test/**/*.test.mjs\"",
|
|
30
|
+
"build:types": "tsc -p tsconfig.json",
|
|
31
|
+
"typecheck": "npm run build:types && node test/types/real-output.mjs && tsc -p test/types && tsc -p test/types/linkedom",
|
|
25
32
|
"size": "node bench/size.mjs",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
33
|
+
"test:browser": "node test/browser/run.mjs",
|
|
34
|
+
"ci": "npm test && npm run typecheck && npm run size",
|
|
35
|
+
"prepack": "npm run build:types",
|
|
36
|
+
"prepublishOnly": "npm run ci",
|
|
37
|
+
"bench": "node bench/compare.mjs",
|
|
38
|
+
"bench:write": "node bench/compare.mjs --write"
|
|
28
39
|
},
|
|
29
40
|
"keywords": [
|
|
30
41
|
"markdown",
|
|
@@ -51,6 +62,7 @@
|
|
|
51
62
|
},
|
|
52
63
|
"devDependencies": {
|
|
53
64
|
"esbuild": "0.27.0",
|
|
54
|
-
"linkedom": "0.18.12"
|
|
65
|
+
"linkedom": "0.18.12",
|
|
66
|
+
"typescript": "5.9.3"
|
|
55
67
|
}
|
|
56
68
|
}
|
package/src/clean.js
CHANGED
|
@@ -13,6 +13,7 @@ export function mapOutsideFences(markdown, transform) {
|
|
|
13
13
|
const blocks = [];
|
|
14
14
|
let lines = [];
|
|
15
15
|
let fence = null;
|
|
16
|
+
let indent = 0;
|
|
16
17
|
const flush = (isCode) => {
|
|
17
18
|
if (!lines.length) return;
|
|
18
19
|
const text = lines.join("\n");
|
|
@@ -20,12 +21,14 @@ export function mapOutsideFences(markdown, transform) {
|
|
|
20
21
|
lines = [];
|
|
21
22
|
};
|
|
22
23
|
for (const line of String(markdown).split("\n")) {
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
// Any indent or quote prefix: Turndown nests code in lists and blockquotes.
|
|
25
|
+
const match = line.match(/^([ >]*)(`{3,}|~{3,})(.*)$/);
|
|
26
|
+
if (!fence && match && !(match[2][0] === "`" && match[3].includes("`"))) {
|
|
25
27
|
flush(false);
|
|
26
|
-
fence = match[
|
|
28
|
+
fence = match[2];
|
|
29
|
+
indent = match[1].length;
|
|
27
30
|
lines.push(line);
|
|
28
|
-
} else if (fence && match && match[1][0] === fence[0] && match[
|
|
31
|
+
} else if (fence && match && match[1].length <= indent + 3 && match[2][0] === fence[0] && match[2].length >= fence.length && !match[3].trim()) {
|
|
29
32
|
lines.push(line);
|
|
30
33
|
flush(true);
|
|
31
34
|
fence = null;
|
|
@@ -81,8 +84,13 @@ export function cleanMarkdown(markdown) {
|
|
|
81
84
|
// Link text may hold escaped brackets and whole images: [](href)
|
|
82
85
|
// keeps the image, not "![alt" pointing at href.
|
|
83
86
|
const LINK_TARGET = String.raw`\((?:[^()\s]|\([^()]*\))+(?:\s+"[^"]*")?\)`;
|
|
84
|
-
const INLINE_LINK = new RegExp(String.raw`(!?)\[((?:\\.|!\[[^\]]*\]${LINK_TARGET}|[^\[\]\\])*)\]${LINK_TARGET}
|
|
85
|
-
|
|
87
|
+
const INLINE_LINK = new RegExp(String.raw`(!?)\[((?:\\.|!\[[^\]]*\]${LINK_TARGET}|[^\[\]\\])*)\]${LINK_TARGET}`);
|
|
88
|
+
// Replaces matches of `pattern`, leaving inline code spans as they are.
|
|
89
|
+
const replaceOutsideCode = (md, pattern, replace) => md.replace(
|
|
90
|
+
new RegExp(`\`+[^\`\\n]*\`+|${pattern.source}`, "g"),
|
|
91
|
+
(all, ...groups) => (all[0] === "`" ? all : replace(all, ...groups)),
|
|
92
|
+
);
|
|
93
|
+
const stripInlineLinks = (md) => replaceOutsideCode(md, INLINE_LINK, (all, bang, text) => (bang ? all : text));
|
|
86
94
|
const dropReferenceDefs = (md) => md.replace(/^\s{0,3}\[[^\]]+]:\s+\S+.*$/gm, "");
|
|
87
95
|
const stripStructure = (md) => md
|
|
88
96
|
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
|
@@ -90,14 +98,18 @@ const stripStructure = (md) => md
|
|
|
90
98
|
.replace(/^\s*\d+\.\s+/gm, "")
|
|
91
99
|
.replace(/^\s{0,3}>\s?/gm, "")
|
|
92
100
|
.replace(/^\s*([-_*])\s*(?:\1\s*){2,}$/gm, "");
|
|
93
|
-
const stripEmphasis = (md) =>
|
|
94
|
-
|
|
101
|
+
const stripEmphasis = (md) => replaceOutsideCode(
|
|
102
|
+
replaceOutsideCode(md, /(\*\*|__)(.+?)\1/, (all, mark, text) => text),
|
|
103
|
+
/(?<![\w*])([*_])(?!\s)(.+?)(?<!\s)\1(?![\w*])/,
|
|
104
|
+
(all, mark, text) => text,
|
|
105
|
+
);
|
|
106
|
+
const stripImages = (md) => replaceOutsideCode(md, /!\[([^\]]*)\]\([^)]*\)/, (all, alt) => alt);
|
|
95
107
|
|
|
96
108
|
/**
|
|
97
109
|
* Output modes. "no_links" keeps link text. "text_only" also drops headings,
|
|
98
110
|
* list markers, emphasis and images. Both leave fenced code verbatim.
|
|
99
111
|
* @param {string} markdown
|
|
100
|
-
* @param {"
|
|
112
|
+
* @param {import("./types.js").OutputMode} [mode]
|
|
101
113
|
*/
|
|
102
114
|
export function applyMode(markdown, mode = "full") {
|
|
103
115
|
if (mode === "no_links") return mapOutsideFences(markdown, stripInlineLinks);
|
package/src/convert.js
CHANGED
|
@@ -14,6 +14,15 @@ export const TURNDOWN_OPTIONS = Object.freeze({
|
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
function gfmInlineRules(turndown) {
|
|
17
|
+
// Link text is one line: a link around blocks would otherwise split across lines.
|
|
18
|
+
turndown.addRule("pmInlineLink", {
|
|
19
|
+
filter: (node) => node.nodeName === "A" && Boolean(node.getAttribute("href")),
|
|
20
|
+
replacement(content, node) {
|
|
21
|
+
const href = node.getAttribute("href").replace(/([()])/g, "\\$1");
|
|
22
|
+
const title = (node.getAttribute("title") || "").replace(/\s*\n+\s*/g, " ").replace(/"/g, '\\"');
|
|
23
|
+
return `[${content.trim().replace(/\s*\n+\s*/g, " ")}](${href}${title ? ` "${title}"` : ""})`;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
17
26
|
turndown.addRule("pmStrikethrough", {
|
|
18
27
|
filter: ["del", "s", "strike"],
|
|
19
28
|
replacement: (content) => `~~${content}~~`,
|
|
@@ -65,25 +74,74 @@ function spanOf(cell) {
|
|
|
65
74
|
return Number.isFinite(span) && span > 1 ? Math.min(span, 50) : 1;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
77
|
+
// Each cell's column, and the row-spanned cells that fill the slots before it
|
|
78
|
+
// (or at the row's end): null for the rest of a cell's column span. A span
|
|
79
|
+
// stops at its row group.
|
|
80
|
+
function tableGrid(rows) {
|
|
81
|
+
const columns = new Map();
|
|
82
|
+
const before = new Map();
|
|
83
|
+
const after = new Map();
|
|
84
|
+
let carry = [];
|
|
85
|
+
let group = null;
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
if (row.parentNode !== group) carry = [];
|
|
88
|
+
group = row.parentNode;
|
|
89
|
+
let column = 0;
|
|
90
|
+
let slots = [];
|
|
91
|
+
const take = (gaps) => {
|
|
92
|
+
while (carry[column]?.left > 0 || (gaps && column < carry.length)) {
|
|
93
|
+
const entry = carry[column];
|
|
94
|
+
slots.push(entry?.left > 0 ? entry.cell : null);
|
|
95
|
+
if (entry?.left > 0) entry.left -= 1;
|
|
96
|
+
column += 1;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const cell of rowCells(row)) {
|
|
100
|
+
slots = [];
|
|
101
|
+
take();
|
|
102
|
+
before.set(cell, slots);
|
|
103
|
+
columns.set(cell, column);
|
|
104
|
+
const left = Number.parseInt(cell.getAttribute?.("rowspan") || "1", 10) - 1;
|
|
105
|
+
const span = spanOf(cell);
|
|
106
|
+
for (let i = 0; i < span; i += 1) {
|
|
107
|
+
if (left > 0) carry[column + i] = { cell: i ? null : cell, left };
|
|
108
|
+
}
|
|
109
|
+
column += span;
|
|
110
|
+
}
|
|
111
|
+
slots = [];
|
|
112
|
+
take(true);
|
|
113
|
+
after.set(row, slots);
|
|
114
|
+
}
|
|
115
|
+
return { columns, before, after };
|
|
76
116
|
}
|
|
77
117
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
118
|
+
const tables = new WeakMap();
|
|
119
|
+
|
|
120
|
+
// Worked out once per table: cell and row rules run for every cell.
|
|
121
|
+
function tableInfo(table) {
|
|
122
|
+
let info = tables.get(table);
|
|
123
|
+
if (info) return info;
|
|
124
|
+
const rows = tableRows(table);
|
|
125
|
+
// Layout tables (nesting, a single cell, a single column) read better as
|
|
126
|
+
// blocks than as a grid.
|
|
127
|
+
const layout = Boolean(table.querySelector?.("table")) || !rows.length
|
|
128
|
+
|| Math.max(...rows.map((row) => rowCells(row).reduce((sum, cell) => sum + spanOf(cell), 0))) <= 1;
|
|
129
|
+
// A GFM cell is one line, so a data table holding code blocks is rendered as
|
|
130
|
+
// blocks too, each cell under its column's header: the code stays fenced.
|
|
131
|
+
const block = layout || Boolean(table.querySelector?.("pre"));
|
|
132
|
+
info = { layout, block, header: columnHeaders(rows[0]), text: new Map(), ...tableGrid(rows) };
|
|
133
|
+
tables.set(table, info);
|
|
134
|
+
return info;
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
function isBlockCell(node) {
|
|
85
138
|
const table = closest(node, "TABLE");
|
|
86
|
-
return !table ||
|
|
139
|
+
return !table || tableInfo(table).block;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A row-spanned cell repeats in each row, so a row reads whole by itself.
|
|
143
|
+
function slotsMarkdown(slots, info) {
|
|
144
|
+
return slots.map((cell) => ` ${info.text.get(cell) || ""} |`).join("");
|
|
87
145
|
}
|
|
88
146
|
|
|
89
147
|
function labelText(cell) {
|
|
@@ -92,8 +150,7 @@ function labelText(cell) {
|
|
|
92
150
|
|
|
93
151
|
// A first row of two or more <th>: column headers. (A single spanning <th>
|
|
94
152
|
// is a title, not a header.)
|
|
95
|
-
function columnHeaders(
|
|
96
|
-
const first = tableRows(table)[0];
|
|
153
|
+
function columnHeaders(first) {
|
|
97
154
|
const cells = first ? rowCells(first) : [];
|
|
98
155
|
return cells.length > 1 && cells.every((cell) => cell.nodeName === "TH") ? first : null;
|
|
99
156
|
}
|
|
@@ -108,16 +165,13 @@ function rowHeader(row) {
|
|
|
108
165
|
// label; "" for layout tables and unlabelled cells.
|
|
109
166
|
function cellLabel(cell) {
|
|
110
167
|
const table = closest(cell, "TABLE");
|
|
111
|
-
|
|
168
|
+
const info = table && tableInfo(table);
|
|
169
|
+
if (!info || info.layout) return "";
|
|
112
170
|
const row = cell.parentNode;
|
|
113
|
-
const header =
|
|
171
|
+
const { header } = info;
|
|
114
172
|
if (header) {
|
|
115
173
|
if (row === header) return null;
|
|
116
|
-
|
|
117
|
-
for (const other of rowCells(row)) {
|
|
118
|
-
if (other === cell) break;
|
|
119
|
-
column += spanOf(other);
|
|
120
|
-
}
|
|
174
|
+
const column = info.columns.get(cell);
|
|
121
175
|
let start = 0;
|
|
122
176
|
for (const th of rowCells(header)) {
|
|
123
177
|
if (column < start + spanOf(th)) return labelText(th);
|
|
@@ -142,25 +196,83 @@ function cellText(content) {
|
|
|
142
196
|
return text.replace(/\|/g, "\\|");
|
|
143
197
|
}
|
|
144
198
|
|
|
199
|
+
// An icon's straight strokes, as lists of points; null if any stroke curves.
|
|
200
|
+
function iconStrokes(svg) {
|
|
201
|
+
const strokes = [];
|
|
202
|
+
for (const shape of Array.from(svg.querySelectorAll?.("path, polyline, line") || [])) {
|
|
203
|
+
const at = (name) => shape.getAttribute(name);
|
|
204
|
+
const d = at("d") ?? `M${at("points") ?? `${at("x1")} ${at("y1")} ${at("x2")} ${at("y2")}`}`;
|
|
205
|
+
const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+/g) || [];
|
|
206
|
+
let x = 0;
|
|
207
|
+
let y = 0;
|
|
208
|
+
let command = "";
|
|
209
|
+
let points = [];
|
|
210
|
+
for (let i = 0; i < tokens.length;) {
|
|
211
|
+
if (/[a-zA-Z]/.test(tokens[i])) command = tokens[i++];
|
|
212
|
+
const relative = command > "Z";
|
|
213
|
+
const type = command.toUpperCase();
|
|
214
|
+
if (!"MLHV".includes(type)) return null;
|
|
215
|
+
if (type !== "V") x = Number(tokens[i++]) + (relative ? x : 0);
|
|
216
|
+
if (type !== "H") y = Number(tokens[i++]) + (relative ? y : 0);
|
|
217
|
+
if (type === "M") {
|
|
218
|
+
points = [];
|
|
219
|
+
strokes.push(points);
|
|
220
|
+
command = relative ? "l" : "L";
|
|
221
|
+
}
|
|
222
|
+
points.push([x, y]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return strokes;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// What an icon's shape says, for the tick, dash and cross of comparison tables.
|
|
229
|
+
function iconShape(strokes) {
|
|
230
|
+
const [first, second] = strokes || [];
|
|
231
|
+
if (strokes?.length === 1 && first.length === 2 && first[0][1] === first[1][1]) return "\u2013";
|
|
232
|
+
if (strokes?.length === 1 && first.length === 3) {
|
|
233
|
+
const [a, b, c] = first;
|
|
234
|
+
const arm = (p) => Math.hypot(p[0] - b[0], p[1] - b[1]);
|
|
235
|
+
const ratio = Math.max(arm(a), arm(c)) / Math.min(arm(a), arm(c));
|
|
236
|
+
if (b[1] > a[1] && b[1] > c[1] && (a[0] - b[0]) * (c[0] - b[0]) < 0 && ratio > 1.3) return "\u2713";
|
|
237
|
+
}
|
|
238
|
+
const slope = ([p, q]) => (q[0] - p[0]) * (q[1] - p[1]);
|
|
239
|
+
if (strokes?.length === 2 && first.length === 2 && second.length === 2 && slope(first) * slope(second) < 0) return "\u2717";
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A cell holding only an icon would be empty, and the table couldn't say which
|
|
244
|
+
// plan has what: its accessible name, else its shape, else that an icon is there.
|
|
245
|
+
function cellIcon(cell) {
|
|
246
|
+
const svg = cell.querySelector?.("svg");
|
|
247
|
+
if (!svg) return "";
|
|
248
|
+
return svg.getAttribute("aria-label")?.trim() || svg.querySelector("title")?.textContent?.trim()
|
|
249
|
+
|| iconShape(iconStrokes(svg)) || "[icon]";
|
|
250
|
+
}
|
|
251
|
+
|
|
145
252
|
function tableRules(turndown) {
|
|
146
253
|
turndown.addRule("pmTableCell", {
|
|
147
254
|
filter: ["th", "td"],
|
|
148
|
-
replacement(
|
|
255
|
+
replacement(rawContent, node) {
|
|
256
|
+
const content = rawContent.trim() ? rawContent : cellIcon(node);
|
|
149
257
|
if (isBlockCell(node)) {
|
|
150
258
|
// Header cells become the labels on the other cells.
|
|
151
259
|
const label = cellLabel(node);
|
|
152
260
|
if (label === null) return "";
|
|
153
261
|
return label ? `\n\n**${createTurndown().escape(label)}**\n\n${content}\n\n` : `\n\n${content}\n\n`;
|
|
154
262
|
}
|
|
263
|
+
const info = tableInfo(closest(node, "TABLE"));
|
|
264
|
+
const text = cellText(content);
|
|
265
|
+
info.text.set(node, text);
|
|
155
266
|
const pad = " | ".repeat(spanOf(node) - 1);
|
|
156
|
-
return
|
|
267
|
+
return `${slotsMarkdown(info.before.get(node), info)} ${text}${pad} |`;
|
|
157
268
|
},
|
|
158
269
|
});
|
|
159
270
|
turndown.addRule("pmTableRow", {
|
|
160
271
|
filter: "tr",
|
|
161
272
|
replacement(content, node) {
|
|
162
273
|
if (isBlockCell(node)) return `\n${content}\n`;
|
|
163
|
-
|
|
274
|
+
const info = tableInfo(closest(node, "TABLE"));
|
|
275
|
+
return `\n|${content}${slotsMarkdown(info.after.get(node), info)}`;
|
|
164
276
|
},
|
|
165
277
|
});
|
|
166
278
|
turndown.addRule("pmTableSection", {
|
|
@@ -172,7 +284,7 @@ function tableRules(turndown) {
|
|
|
172
284
|
turndown.addRule("pmTable", {
|
|
173
285
|
filter: "table",
|
|
174
286
|
replacement(content, node) {
|
|
175
|
-
if (
|
|
287
|
+
if (tableInfo(node).block) return `\n\n${content}\n\n`;
|
|
176
288
|
const lines = content.split("\n").filter((line) => line.startsWith("|"));
|
|
177
289
|
if (!lines.length) return "\n\n";
|
|
178
290
|
const width = Math.max(...lines.map((line) => line.split(/(?<!\\)\|/).length - 2));
|
|
@@ -197,7 +309,10 @@ function tableRules(turndown) {
|
|
|
197
309
|
|
|
198
310
|
let shared = null;
|
|
199
311
|
|
|
200
|
-
/**
|
|
312
|
+
/**
|
|
313
|
+
* A configured Turndown instance (cached).
|
|
314
|
+
* @returns {import("./types.js").TurndownInstance}
|
|
315
|
+
*/
|
|
201
316
|
export function createTurndown() {
|
|
202
317
|
if (shared) return shared;
|
|
203
318
|
const turndown = new TurndownService({ ...TURNDOWN_OPTIONS });
|