@page2ai/core 0.1.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/browser/browser-adapter.d.ts +15 -0
  4. package/dist/browser/browser-adapter.d.ts.map +1 -0
  5. package/dist/browser/browser-adapter.js +74 -0
  6. package/dist/browser/browser-adapter.js.map +1 -0
  7. package/dist/browser/index.d.ts +3 -0
  8. package/dist/browser/index.d.ts.map +1 -0
  9. package/dist/browser/index.js +7 -0
  10. package/dist/browser/index.js.map +1 -0
  11. package/dist/node/basic-renderer.d.ts +9 -0
  12. package/dist/node/basic-renderer.d.ts.map +1 -0
  13. package/dist/node/basic-renderer.js +347 -0
  14. package/dist/node/basic-renderer.js.map +1 -0
  15. package/dist/node/fetch-protected.d.ts +19 -0
  16. package/dist/node/fetch-protected.d.ts.map +1 -0
  17. package/dist/node/fetch-protected.js +92 -0
  18. package/dist/node/fetch-protected.js.map +1 -0
  19. package/dist/node/index.d.ts +28 -0
  20. package/dist/node/index.d.ts.map +1 -0
  21. package/dist/node/index.js +136 -0
  22. package/dist/node/index.js.map +1 -0
  23. package/dist/node/linkedom-adapter.d.ts +17 -0
  24. package/dist/node/linkedom-adapter.d.ts.map +1 -0
  25. package/dist/node/linkedom-adapter.js +112 -0
  26. package/dist/node/linkedom-adapter.js.map +1 -0
  27. package/dist/node/ssrf-guard.d.ts +6 -0
  28. package/dist/node/ssrf-guard.d.ts.map +1 -0
  29. package/dist/node/ssrf-guard.js +73 -0
  30. package/dist/node/ssrf-guard.js.map +1 -0
  31. package/dist/shared/constants.d.ts +12 -0
  32. package/dist/shared/constants.d.ts.map +1 -0
  33. package/dist/shared/constants.js +164 -0
  34. package/dist/shared/constants.js.map +1 -0
  35. package/dist/shared/md-postprocess.d.ts +18 -0
  36. package/dist/shared/md-postprocess.d.ts.map +1 -0
  37. package/dist/shared/md-postprocess.js +538 -0
  38. package/dist/shared/md-postprocess.js.map +1 -0
  39. package/dist/shared/page-adapter.d.ts +26 -0
  40. package/dist/shared/page-adapter.d.ts.map +1 -0
  41. package/dist/shared/page-adapter.js +15 -0
  42. package/dist/shared/page-adapter.js.map +1 -0
  43. package/dist/shared/profiles.d.ts +6 -0
  44. package/dist/shared/profiles.d.ts.map +1 -0
  45. package/dist/shared/profiles.js +185 -0
  46. package/dist/shared/profiles.js.map +1 -0
  47. package/dist/shared/types.d.ts +265 -0
  48. package/dist/shared/types.d.ts.map +1 -0
  49. package/dist/shared/types.js +4 -0
  50. package/dist/shared/types.js.map +1 -0
  51. package/dist/shared/utils.d.ts +21 -0
  52. package/dist/shared/utils.d.ts.map +1 -0
  53. package/dist/shared/utils.js +184 -0
  54. package/dist/shared/utils.js.map +1 -0
  55. package/package.json +81 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Saevets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @page2ai/core
2
+
3
+ Extract clean Markdown from any web page HTML. Works in Node.js (via linkedom) and in browsers. Powers the [Page2AI extension](https://github.com/igorsaevets/page2ai-extension) and the [`@page2ai/mcp` server](https://github.com/igorsaevets/page2ai-mcp).
4
+
5
+ - **Node.js**: `htmlToMarkdown(html, opts)` or `fetchAndConvert(url, opts)` — zero external API calls, uses linkedom for parsing
6
+ - **Browser**: exports `BrowserAdapter` and the `PageAdapter` interface; the full extract pipeline lives in the extension repo (planned for v0.2 migration)
7
+ - **Zero data collection**: no telemetry, no analytics, no third-party calls
8
+ - **MIT** licensed
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @page2ai/core
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```javascript
19
+ import { htmlToMarkdown, fetchAndConvert } from '@page2ai/core';
20
+
21
+ // From an HTML string
22
+ const html = `<article><h1>Hello</h1><p>World</p></article>`;
23
+ const { markdown, title, charCount } = htmlToMarkdown(html, {
24
+ baseUrl: 'https://example.com/hello',
25
+ includeFrontmatter: true,
26
+ });
27
+ console.log(markdown);
28
+
29
+ // From a URL
30
+ const result = await fetchAndConvert('https://docs.anthropic.com/en/api/messages', {
31
+ timeoutMs: 15000,
32
+ });
33
+ console.log(result.markdown);
34
+ ```
35
+
36
+ ## API
37
+
38
+ ### `htmlToMarkdown(html, opts?)`
39
+
40
+ Converts an HTML string to Markdown. Synchronous.
41
+
42
+ - `html` (string) — the HTML to parse
43
+ - `opts.baseUrl` (string) — URL used to resolve relative links (default: `'about:blank'`)
44
+ - `opts.profile` (string) — extraction profile: `'auto' | 'docs' | 'marketing' | 'research' | 'dashboard' | 'wordpress-marketing'` (default: `'auto'`, v0.2)
45
+ - `opts.includeFrontmatter` (boolean) — emit YAML frontmatter block (default: `true`)
46
+ - `opts.includeImages` (boolean) — emit image links (default: `true`)
47
+
48
+ Returns `{ markdown, title, baseUrl, charCount, extractedAt }`.
49
+
50
+ ### `fetchAndConvert(url, opts?)`
51
+
52
+ Fetches a URL and converts it to Markdown. Async.
53
+
54
+ - `url` (string) — the URL to fetch
55
+ - `opts.timeoutMs` (number) — abort after N ms (default: `15000`)
56
+ - `opts.userAgent` (string) — custom user agent (default: `page2ai-core/0.1`)
57
+ - ...all `htmlToMarkdown` options
58
+
59
+ Uses Node 18+ built-in `fetch()`. Follows redirects; passes the final URL to `htmlToMarkdown` as `baseUrl` unless overridden.
60
+
61
+ ## What it does
62
+
63
+ - Parses HTML with [linkedom](https://github.com/WebReflection/linkedom) (fast, browser-DOM-fidelity, no layout engine)
64
+ - Picks a content root (`main article` > `article` > `main` > `[role="main"]` > `body`)
65
+ - Emits: headings (`# `), paragraphs, lists (ordered/unordered, nested), links `[text](url)`, images `![alt](src)`, code blocks (with language tag when detectable), tables (`| a | b |`), blockquotes, inline formatting (bold, italic, code)
66
+ - Extracts metadata into YAML frontmatter: `title`, `source`, `captured_at`, `language`, `description`, `canonical`, `og_title`, `og_description`, `author`, `published`, `extractor`, `extractor_version`
67
+
68
+ ## What it does NOT do (v0.1)
69
+
70
+ - No tab/dropdown expansion (browser-only interactive machinery)
71
+ - No lazy-load activation (Node has no runtime images)
72
+ - No `<details>` auto-expansion
73
+ - No computed-style-based visibility filtering (linkedom has no layout engine)
74
+ - No badge detection via font weight
75
+ - No shadow DOM traversal (linkedom has no shadow DOM support)
76
+ - No pseudo-element `::before` / `::after` content
77
+
78
+ **All the above ship with the Chrome/Firefox extension**, which runs the full `lib/core/*` pipeline against a live browser DOM. See [github.com/igorsaevets/page2ai-extension](https://github.com/igorsaevets/page2ai-extension).
79
+
80
+ ## Roadmap
81
+
82
+ - **v0.1** (current) — Node basic renderer, browser adapter interface published
83
+ - **v0.2** — full port of `lib/core/html-to-md.ts` via PageAdapter, browser entry migrated from extension repo
84
+ - **v0.3** — profile auto-detection in Node path, structured-data extraction
85
+ - **v0.4** — official-markdown fallback (llms.txt, .md sibling URLs), MDX handling
86
+
87
+ ## Architecture
88
+
89
+ Two implementations behind a single `PageAdapter` interface:
90
+
91
+ ```
92
+ src/
93
+ ├── shared/ # No DOM assumptions — works with any adapter
94
+ │ ├── page-adapter.ts # PageAdapter interface + defaults + nodeCssEscape
95
+ │ ├── utils.ts # pure helpers (absUrl, escapeMd, etc.)
96
+ │ ├── md-postprocess.ts # pure string transforms
97
+ │ ├── types.ts # all TypeScript interfaces
98
+ │ ├── constants.ts # DEFAULTS, BLOCK_TAGS, PII patterns
99
+ │ └── profiles.ts # profile presets
100
+ ├── node/ # LinkedomAdapter + htmlToMarkdown + basic-renderer
101
+ │ ├── index.ts
102
+ │ ├── linkedom-adapter.ts
103
+ │ └── basic-renderer.ts
104
+ └── browser/ # BrowserAdapter (v0.1) + extract() (v0.2)
105
+ ├── index.ts
106
+ └── browser-adapter.ts
107
+ ```
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ git clone https://github.com/igorsaevets/page2ai-core
113
+ cd page2ai-core
114
+ npm install
115
+ npm run build # tsc → dist/
116
+ npm test # vitest
117
+ npm run typecheck # tsc --noEmit
118
+ ```
119
+
120
+ ## Related
121
+
122
+ - [Page2AI Chrome extension](https://github.com/igorsaevets/page2ai-extension) — the human-facing browser extension
123
+ - [`@page2ai/mcp`](https://github.com/igorsaevets/page2ai-mcp) — MCP server for Claude Desktop, Cursor, Windsurf, Zed
124
+ - [Software Heritage archive](https://archive.softwareheritage.org/) — SWHID `swh:1:snp:05123c51ef9e7c0aeb06f42b1263c07a8d26999a`
125
+
126
+ ## License
127
+
128
+ MIT — Copyright (c) 2026 Igor Saevets. See [LICENSE](./LICENSE).
@@ -0,0 +1,15 @@
1
+ import type { AdapterCapabilities, ComputedStyleLike, PageAdapter } from '../shared/page-adapter.js';
2
+ export declare class BrowserAdapter implements PageAdapter {
3
+ readonly capabilities: Readonly<AdapterCapabilities>;
4
+ private cache;
5
+ getComputedStyle(el: Element): ComputedStyleLike;
6
+ getPseudoContent(el: Element, pseudo: '::before' | '::after'): string;
7
+ innerText(el: Element): string;
8
+ shadowRoot(el: Element): ShadowRoot | null;
9
+ currentSrc(img: Element): string;
10
+ baseUrl(): string;
11
+ cssEscape(s: string): string;
12
+ document(): Document;
13
+ resetCache(): void;
14
+ }
15
+ //# sourceMappingURL=browser-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-adapter.d.ts","sourceRoot":"","sources":["../../src/browser/browser-adapter.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAEnC,qBAAa,cAAe,YAAW,WAAW;IAChD,QAAQ,CAAC,YAAY,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAMjD;IAEH,OAAO,CAAC,KAAK,CAA+C;IAE5D,gBAAgB,CAAC,EAAE,EAAE,OAAO,GAAG,iBAAiB;IAehD,gBAAgB,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,MAAM;IAgBrE,SAAS,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM;IAI9B,UAAU,CAAC,EAAE,EAAE,OAAO,GAAG,UAAU,GAAG,IAAI;IAI1C,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM;IAIhC,OAAO,IAAI,MAAM;IAIjB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM;IAK5B,QAAQ,IAAI,QAAQ;IAIpB,UAAU,IAAI,IAAI;CAGnB"}
@@ -0,0 +1,74 @@
1
+ // BrowserAdapter: PageAdapter implementation for real browsers (Chrome/Firefox
2
+ // extensions). Thin wrapper around window.getComputedStyle, HTMLElement.innerText,
3
+ // Element.shadowRoot, HTMLImageElement.currentSrc, CSS.escape, location.href.
4
+ //
5
+ // Per-instance computedStyleCache (Spark P20 fix — the extension previously had
6
+ // a module-level WeakMap which leaked in long-lived contexts and races on
7
+ // concurrent extractions). Callers should create ONE BrowserAdapter per
8
+ // extraction call and discard when done.
9
+ export class BrowserAdapter {
10
+ capabilities = Object.freeze({
11
+ canComputeStyle: true,
12
+ canPseudoContent: true,
13
+ canShadowDom: true,
14
+ canInnerText: true,
15
+ canCurrentSrc: true,
16
+ });
17
+ cache = new WeakMap();
18
+ getComputedStyle(el) {
19
+ let s = this.cache.get(el);
20
+ if (!s) {
21
+ s = window.getComputedStyle(el);
22
+ this.cache.set(el, s);
23
+ }
24
+ return {
25
+ display: s.display,
26
+ visibility: s.visibility,
27
+ opacity: s.opacity,
28
+ fontSize: s.fontSize,
29
+ fontWeight: s.fontWeight,
30
+ };
31
+ }
32
+ getPseudoContent(el, pseudo) {
33
+ try {
34
+ const s = window.getComputedStyle(el, pseudo);
35
+ let c = s.getPropertyValue('content') || '';
36
+ if (!c || c === 'none' || c === 'normal')
37
+ return '';
38
+ c = c.trim();
39
+ if ((c.startsWith('"') && c.endsWith('"')) || (c.startsWith("'") && c.endsWith("'"))) {
40
+ c = c.slice(1, -1);
41
+ }
42
+ if (/counter\(|counters\(/i.test(c))
43
+ return '';
44
+ return c.replace(/ /g, ' ').replace(/[ \t\r\n]+/g, ' ').trim();
45
+ }
46
+ catch {
47
+ return '';
48
+ }
49
+ }
50
+ innerText(el) {
51
+ return el.innerText || el.textContent || '';
52
+ }
53
+ shadowRoot(el) {
54
+ return el.shadowRoot ?? null;
55
+ }
56
+ currentSrc(img) {
57
+ return img.currentSrc || '';
58
+ }
59
+ baseUrl() {
60
+ return location.href;
61
+ }
62
+ cssEscape(s) {
63
+ if (typeof CSS !== 'undefined' && CSS.escape)
64
+ return CSS.escape(s);
65
+ return s.replace(/([^\w-])/g, '\\$1');
66
+ }
67
+ document() {
68
+ return document;
69
+ }
70
+ resetCache() {
71
+ this.cache = new WeakMap();
72
+ }
73
+ }
74
+ //# sourceMappingURL=browser-adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-adapter.js","sourceRoot":"","sources":["../../src/browser/browser-adapter.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,mFAAmF;AACnF,8EAA8E;AAC9E,EAAE;AACF,gFAAgF;AAChF,0EAA0E;AAC1E,wEAAwE;AACxE,yCAAyC;AAQzC,MAAM,OAAO,cAAc;IAChB,YAAY,GAAkC,MAAM,CAAC,MAAM,CAAC;QACnE,eAAe,EAAE,IAAI;QACrB,gBAAgB,EAAE,IAAI;QACtB,YAAY,EAAE,IAAI;QAClB,YAAY,EAAE,IAAI;QAClB,aAAa,EAAE,IAAI;KACpB,CAAC,CAAC;IAEK,KAAK,GAAG,IAAI,OAAO,EAAgC,CAAC;IAE5D,gBAAgB,CAAC,EAAW;QAC1B,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC3B,IAAI,CAAC,CAAC,EAAE,CAAC;YACP,CAAC,GAAG,MAAM,CAAC,gBAAgB,CAAC,EAAE,CAAC,CAAC;YAChC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;QACxB,CAAC;QACD,OAAO;YACL,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,UAAU,EAAE,CAAC,CAAC,UAAU;YACxB,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,UAAU,EAAE,CAAC,CAAC,UAAU;SACzB,CAAC;IACJ,CAAC;IAED,gBAAgB,CAAC,EAAW,EAAE,MAA8B;QAC1D,IAAI,CAAC;YACH,MAAM,CAAC,GAAG,MAAM,CAAC,gBAAgB,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;YAC9C,IAAI,CAAC,GAAG,CAAC,CAAC,gBAAgB,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;YAC5C,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,KAAK,QAAQ;gBAAE,OAAO,EAAE,CAAC;YACpD,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;YACb,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;gBACrF,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;YACrB,CAAC;YACD,IAAI,uBAAuB,CAAC,IAAI,CAAC,CAAC,CAAC;gBAAE,OAAO,EAAE,CAAC;YAC/C,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;QACjE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC;IAED,SAAS,CAAC,EAAW;QACnB,OAAQ,EAAkB,CAAC,SAAS,IAAI,EAAE,CAAC,WAAW,IAAI,EAAE,CAAC;IAC/D,CAAC;IAED,UAAU,CAAC,EAAW;QACpB,OAAO,EAAE,CAAC,UAAU,IAAI,IAAI,CAAC;IAC/B,CAAC;IAED,UAAU,CAAC,GAAY;QACrB,OAAQ,GAAwB,CAAC,UAAU,IAAI,EAAE,CAAC;IACpD,CAAC;IAED,OAAO;QACL,OAAO,QAAQ,CAAC,IAAI,CAAC;IACvB,CAAC;IAED,SAAS,CAAC,CAAS;QACjB,IAAI,OAAO,GAAG,KAAK,WAAW,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACnE,OAAO,CAAC,CAAC,OAAO,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IACxC,CAAC;IAED,QAAQ;QACN,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,UAAU;QACR,IAAI,CAAC,KAAK,GAAG,IAAI,OAAO,EAAE,CAAC;IAC7B,CAAC;CACF"}
@@ -0,0 +1,3 @@
1
+ export { BrowserAdapter } from './browser-adapter.js';
2
+ export type { AdapterCapabilities, ComputedStyleLike, PageAdapter, } from '../shared/page-adapter.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/browser/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,iBAAiB,EACjB,WAAW,GACZ,MAAM,2BAA2B,CAAC"}
@@ -0,0 +1,7 @@
1
+ // @page2ai/core browser entry point.
2
+ //
3
+ // v0.1 SCOPE: exports the BrowserAdapter only. The full extract() pipeline
4
+ // still lives in the Page2AI extension repo (lib/core/*). v0.2 will migrate
5
+ // the extension to import from this package.
6
+ export { BrowserAdapter } from './browser-adapter.js';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/browser/index.ts"],"names":[],"mappings":"AAAA,qCAAqC;AACrC,EAAE;AACF,2EAA2E;AAC3E,4EAA4E;AAC5E,6CAA6C;AAE7C,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,9 @@
1
+ import type { PageAdapter } from '../shared/page-adapter.js';
2
+ interface RenderOpts {
3
+ baseUrl: string;
4
+ includeFrontmatter: boolean;
5
+ includeImages: boolean;
6
+ }
7
+ export declare const renderBasicMarkdown: (doc: Document, adapter: PageAdapter, opts: RenderOpts) => string;
8
+ export {};
9
+ //# sourceMappingURL=basic-renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"basic-renderer.d.ts","sourceRoot":"","sources":["../../src/node/basic-renderer.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAS7D,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,aAAa,EAAE,OAAO,CAAC;CACxB;AAUD,eAAO,MAAM,mBAAmB,GAC9B,KAAK,QAAQ,EACb,SAAS,WAAW,EACpB,MAAM,UAAU,KACf,MAyBF,CAAC"}
@@ -0,0 +1,347 @@
1
+ // Basic Markdown renderer for the v0.1 Node build. Covers the essentials:
2
+ // headings, paragraphs, lists, links, images, code blocks, tables, blockquotes,
3
+ // inline formatting, YAML frontmatter, plus STATIC tab discovery emitting
4
+ // '## Tab: {label}' sections (Spark P20 fix — prevents Python+TypeScript panel
5
+ // concatenation on docs.anthropic.com, platform.openai.com, Mintlify, etc.).
6
+ // NOT a port of the full extension renderer.
7
+ //
8
+ // v0.2 will replace this with the full lib/core/html-to-md.ts pipeline via
9
+ // PageAdapter, once the browser renderer is refactored to accept an adapter.
10
+ import { absUrl, chooseCodeFence, cleanInline, escapeMd, escapeMdTableCell } from '../shared/utils.js';
11
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS']);
12
+ const INLINE_TAGS = new Set(['A', 'B', 'STRONG', 'I', 'EM', 'CODE', 'SPAN', 'SMALL', 'MARK', 'U', 'S', 'DEL', 'INS', 'SUB', 'SUP', 'ABBR', 'CITE', 'Q', 'DFN', 'KBD', 'SAMP', 'VAR', 'TIME']);
13
+ const KNOWN_LANGS = new Set(['bash', 'sh', 'shell', 'python', 'py', 'javascript', 'js', 'typescript', 'ts', 'tsx', 'jsx', 'json', 'yaml', 'yml', 'toml', 'html', 'css', 'sql', 'go', 'rust', 'java', 'kotlin', 'swift', 'c', 'cpp', 'csharp', 'php', 'ruby', 'markdown', 'md', 'mermaid', 'dockerfile', 'text']);
14
+ const yq = (s) => '"' + String(s).replace(/"/g, '\\"') + '"';
15
+ export const renderBasicMarkdown = (doc, adapter, opts) => {
16
+ const parts = [];
17
+ if (opts.includeFrontmatter) {
18
+ parts.push(...buildFrontmatter(doc, opts.baseUrl));
19
+ }
20
+ const title = cleanInline(doc.querySelector('title')?.textContent || '');
21
+ if (title)
22
+ parts.push(`# ${title}`, '');
23
+ const root = pickContentRoot(doc);
24
+ if (root && root.children) {
25
+ const tabGroups = discoverTabGroups(root);
26
+ const capturedPanelEls = new WeakSet();
27
+ tabGroups.forEach((g) => g.panels.forEach((p) => capturedPanelEls.add(p.el)));
28
+ const lines = [];
29
+ void adapter;
30
+ walkWithTabs(root, lines, 0, opts, tabGroups, capturedPanelEls);
31
+ parts.push(...lines);
32
+ }
33
+ else {
34
+ parts.push('', '<!-- AI: page has no extractable body content -->', '');
35
+ }
36
+ return normalizeBlank(parts.join('\n')).trim() + '\n';
37
+ };
38
+ const buildFrontmatter = (doc, baseUrl) => {
39
+ const title = cleanInline(doc.querySelector('title')?.textContent || '');
40
+ const description = cleanInline(doc.querySelector('meta[name="description"]')?.getAttribute('content') || '');
41
+ const lang = doc.documentElement?.getAttribute('lang') || 'unknown';
42
+ const canonical = absUrl(doc.querySelector('link[rel="canonical"]')?.getAttribute('href') || '', baseUrl);
43
+ const ogTitle = cleanInline(doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || '');
44
+ const ogDescription = cleanInline(doc.querySelector('meta[property="og:description"]')?.getAttribute('content') || '');
45
+ const author = cleanInline(doc.querySelector('meta[name="author"]')?.getAttribute('content') ||
46
+ doc.querySelector('meta[property="article:author"]')?.getAttribute('content') ||
47
+ '');
48
+ const publishedTime = cleanInline(doc.querySelector('meta[property="article:published_time"]')?.getAttribute('content') || '');
49
+ const fm = ['---'];
50
+ fm.push(`title: ${yq(title)}`);
51
+ fm.push(`source: ${yq(baseUrl)}`);
52
+ fm.push(`captured_at: ${yq(new Date().toISOString())}`);
53
+ fm.push(`language: ${yq(lang)}`);
54
+ if (description)
55
+ fm.push(`description: ${yq(description)}`);
56
+ if (canonical)
57
+ fm.push(`canonical: ${yq(canonical)}`);
58
+ if (ogTitle && ogTitle !== title)
59
+ fm.push(`og_title: ${yq(ogTitle)}`);
60
+ if (ogDescription && ogDescription !== description)
61
+ fm.push(`og_description: ${yq(ogDescription)}`);
62
+ if (author)
63
+ fm.push(`author: ${yq(author)}`);
64
+ if (publishedTime)
65
+ fm.push(`published: ${yq(publishedTime)}`);
66
+ fm.push(`extractor: ${yq('page2ai-core')}`);
67
+ fm.push(`extractor_version: ${yq('0.1.0')}`);
68
+ fm.push('---', '');
69
+ return fm;
70
+ };
71
+ const pickContentRoot = (doc) => {
72
+ const cand = doc.querySelector('main article') ||
73
+ doc.querySelector('article') ||
74
+ doc.querySelector('main') ||
75
+ doc.querySelector('[role="main"]') ||
76
+ doc.body ||
77
+ doc.documentElement ||
78
+ null;
79
+ return cand || null;
80
+ };
81
+ // Static tab discovery: aria-controls chains, role="tablist" siblings, and
82
+ // data-state attribute pairs. Emits '## Tab: {label}' sections. Does NOT
83
+ // click — for Node we treat each panel as ALREADY present in the DOM.
84
+ const discoverTabGroups = (root) => {
85
+ const groups = [];
86
+ const seenContainers = new WeakSet();
87
+ // Path 1: [role="tablist"] with aria-controls chain
88
+ root.querySelectorAll('[role="tablist"]').forEach((tl) => {
89
+ if (seenContainers.has(tl))
90
+ return;
91
+ const tabs = [...tl.querySelectorAll('[role="tab"], button')];
92
+ const panels = [];
93
+ for (const tab of tabs) {
94
+ const label = cleanInline(tab.textContent || '');
95
+ if (!label)
96
+ continue;
97
+ const controls = tab.getAttribute('aria-controls');
98
+ if (!controls)
99
+ continue;
100
+ // linkedom supports getElementById via document.getElementById
101
+ const doc = tl.ownerDocument;
102
+ if (!doc)
103
+ continue;
104
+ const panel = doc.getElementById(controls);
105
+ if (!panel)
106
+ continue;
107
+ panels.push({ label, el: panel });
108
+ }
109
+ if (panels.length >= 2) {
110
+ groups.push({ container: tl, panels });
111
+ seenContainers.add(tl);
112
+ }
113
+ });
114
+ // Path 2: data-state="active"/"inactive" panels sharing a parent
115
+ root.querySelectorAll('[data-state="active"], [data-state="inactive"]').forEach((el) => {
116
+ if (el.getAttribute('role') !== 'tabpanel')
117
+ return;
118
+ const parent = el.parentElement;
119
+ if (!parent || seenContainers.has(parent))
120
+ return;
121
+ const siblingPanels = [...parent.children].filter((c) => c.getAttribute('role') === 'tabpanel');
122
+ if (siblingPanels.length < 2)
123
+ return;
124
+ const panels = siblingPanels.map((p) => ({
125
+ label: p.getAttribute('aria-labelledby') ||
126
+ p.getAttribute('aria-label') ||
127
+ p.getAttribute('data-value') ||
128
+ p.id ||
129
+ 'Panel',
130
+ el: p,
131
+ }));
132
+ groups.push({ container: parent, panels });
133
+ seenContainers.add(parent);
134
+ });
135
+ return groups;
136
+ };
137
+ const walkWithTabs = (el, out, depth, opts, tabGroups, capturedPanelEls) => {
138
+ for (const child of el.children) {
139
+ renderNodeWithTabs(child, out, depth, opts, tabGroups, capturedPanelEls);
140
+ }
141
+ };
142
+ const renderNodeWithTabs = (el, out, depth, opts, tabGroups, capturedPanelEls) => {
143
+ // If THIS element is a captured tab panel, skip — it will be rendered as
144
+ // part of its tab group section, not inline.
145
+ if (capturedPanelEls.has(el))
146
+ return;
147
+ // If THIS element is a tab-group container, render its panels as sections.
148
+ const group = tabGroups.find((g) => g.container === el);
149
+ if (group) {
150
+ for (const panel of group.panels) {
151
+ out.push('', `### Tab: ${escapeMd(panel.label)}`, '');
152
+ walkWithTabs(panel.el, out, depth, opts, tabGroups, capturedPanelEls);
153
+ }
154
+ return;
155
+ }
156
+ renderNode(el, out, depth, opts, tabGroups, capturedPanelEls);
157
+ };
158
+ const renderNode = (el, out, depth, opts, tabGroups, capturedPanelEls) => {
159
+ const tag = el.tagName;
160
+ if (SKIP_TAGS.has(tag))
161
+ return;
162
+ if (el.getAttribute('aria-hidden') === 'true')
163
+ return;
164
+ const pfx = ' '.repeat(depth);
165
+ if (/^H[1-6]$/.test(tag)) {
166
+ const level = Number(tag.slice(1));
167
+ const text = renderInline(el, opts).trim();
168
+ if (text)
169
+ out.push('', `${'#'.repeat(level)} ${text}`, '');
170
+ return;
171
+ }
172
+ if (tag === 'PRE') {
173
+ renderCodeBlock(el, out);
174
+ return;
175
+ }
176
+ if (tag === 'P') {
177
+ const text = renderInline(el, opts).trim();
178
+ if (text)
179
+ out.push('', text, '');
180
+ return;
181
+ }
182
+ if (tag === 'A') {
183
+ const href = absUrl(el.getAttribute('href') || '', opts.baseUrl);
184
+ const text = renderInline(el, opts).trim() || cleanInline(el.textContent || '');
185
+ if (text && href)
186
+ out.push(`${pfx}[${escapeMd(text)}](${href})`);
187
+ else if (text)
188
+ out.push(`${pfx}${escapeMd(text)}`);
189
+ return;
190
+ }
191
+ if (tag === 'BLOCKQUOTE') {
192
+ const inner = [];
193
+ walkWithTabs(el, inner, depth, opts, tabGroups, capturedPanelEls);
194
+ const text = inner.join('\n').trim();
195
+ if (text) {
196
+ out.push('');
197
+ text.split('\n').forEach((l) => out.push(`> ${l}`));
198
+ out.push('');
199
+ }
200
+ return;
201
+ }
202
+ if (tag === 'UL' || tag === 'OL') {
203
+ const items = [...el.children].filter((c) => c.tagName === 'LI');
204
+ let i = 1;
205
+ for (const li of items) {
206
+ const marker = tag === 'OL' ? `${i}.` : '-';
207
+ const text = renderInline(li, opts).trim();
208
+ if (text)
209
+ out.push(`${pfx}${marker} ${text}`);
210
+ for (const sub of li.children) {
211
+ if (sub.tagName === 'UL' || sub.tagName === 'OL') {
212
+ renderNodeWithTabs(sub, out, depth + 1, opts, tabGroups, capturedPanelEls);
213
+ }
214
+ }
215
+ i++;
216
+ }
217
+ return;
218
+ }
219
+ if (tag === 'TABLE') {
220
+ renderTable(el, out);
221
+ return;
222
+ }
223
+ if (tag === 'IMG') {
224
+ if (!opts.includeImages)
225
+ return;
226
+ const alt = cleanInline(el.getAttribute('alt') || '');
227
+ const src = absUrl(el.getAttribute('src') || el.getAttribute('data-src') || '', opts.baseUrl);
228
+ if (src)
229
+ out.push(`${pfx}![${escapeMd(alt || 'image')}](${src})`);
230
+ return;
231
+ }
232
+ if (tag === 'HR') {
233
+ out.push('', '---', '');
234
+ return;
235
+ }
236
+ if (tag === 'BR') {
237
+ out.push('');
238
+ return;
239
+ }
240
+ walkWithTabs(el, out, depth, opts, tabGroups, capturedPanelEls);
241
+ };
242
+ const renderInline = (el, opts) => {
243
+ const parts = [];
244
+ for (const node of el.childNodes) {
245
+ if (node.nodeType === 3 /* TEXT_NODE */) {
246
+ const t = cleanInline(node.textContent || '');
247
+ if (t)
248
+ parts.push(t);
249
+ continue;
250
+ }
251
+ if (node.nodeType !== 1 /* ELEMENT_NODE */)
252
+ continue;
253
+ const c = node;
254
+ const tag = c.tagName;
255
+ if (SKIP_TAGS.has(tag))
256
+ continue;
257
+ if (tag === 'BR') {
258
+ parts.push('\n');
259
+ continue;
260
+ }
261
+ if (tag === 'A') {
262
+ const href = absUrl(c.getAttribute('href') || '', opts.baseUrl);
263
+ const text = renderInline(c, opts) || cleanInline(c.textContent || '');
264
+ if (text && href)
265
+ parts.push(`[${escapeMd(text)}](${href})`);
266
+ else if (text)
267
+ parts.push(escapeMd(text));
268
+ continue;
269
+ }
270
+ if (tag === 'STRONG' || tag === 'B') {
271
+ const t = renderInline(c, opts) || cleanInline(c.textContent || '');
272
+ if (t)
273
+ parts.push(`**${t}**`);
274
+ continue;
275
+ }
276
+ if (tag === 'EM' || tag === 'I') {
277
+ const t = renderInline(c, opts) || cleanInline(c.textContent || '');
278
+ if (t)
279
+ parts.push(`*${t}*`);
280
+ continue;
281
+ }
282
+ if (tag === 'CODE') {
283
+ const t = cleanInline(c.textContent || '');
284
+ if (t)
285
+ parts.push(`\`${t}\``);
286
+ continue;
287
+ }
288
+ if (tag === 'IMG') {
289
+ const alt = cleanInline(c.getAttribute('alt') || '');
290
+ const src = absUrl(c.getAttribute('src') || '', opts.baseUrl);
291
+ if (src)
292
+ parts.push(`![${escapeMd(alt || 'image')}](${src})`);
293
+ continue;
294
+ }
295
+ if (INLINE_TAGS.has(tag)) {
296
+ const t = renderInline(c, opts) || cleanInline(c.textContent || '');
297
+ if (t)
298
+ parts.push(t);
299
+ continue;
300
+ }
301
+ const t = cleanInline(c.textContent || '');
302
+ if (t)
303
+ parts.push(t);
304
+ }
305
+ return parts.join(' ').replace(/\s+\n\s+/g, '\n');
306
+ };
307
+ const renderCodeBlock = (pre, out) => {
308
+ const codeEl = pre.querySelector('code') || pre;
309
+ const text = (codeEl.textContent || '').replace(/\r/g, '').replace(/^\n+|\n+$/g, '');
310
+ if (!text.trim())
311
+ return;
312
+ let lang = '';
313
+ const codeClass = codeEl.getAttribute('class') || pre.getAttribute('class') || '';
314
+ const langMatch = codeClass.match(/language-([a-z0-9_+#.-]+)/i);
315
+ if (langMatch) {
316
+ const l = langMatch[1].toLowerCase();
317
+ if (KNOWN_LANGS.has(l) || /^[a-z][a-z0-9]{0,15}$/.test(l))
318
+ lang = l;
319
+ }
320
+ const dataLang = pre.getAttribute('data-language') || codeEl.getAttribute('data-language') || '';
321
+ if (!lang && dataLang) {
322
+ const l = dataLang.toLowerCase();
323
+ if (KNOWN_LANGS.has(l) || /^[a-z][a-z0-9]{0,15}$/.test(l))
324
+ lang = l;
325
+ }
326
+ const fence = chooseCodeFence(text);
327
+ out.push('', `${fence}${lang}`);
328
+ text.split('\n').forEach((l) => out.push(l));
329
+ out.push(fence, '');
330
+ };
331
+ const renderTable = (tbl, out) => {
332
+ const rows = [...tbl.querySelectorAll('tr')];
333
+ if (!rows.length)
334
+ return;
335
+ const data = rows.map((tr) => [...tr.querySelectorAll('th, td')].map((c) => escapeMdTableCell(c.textContent || '')));
336
+ const width = Math.max(...data.map((r) => r.length));
337
+ const padded = data.map((r) => { while (r.length < width)
338
+ r.push(''); return r; });
339
+ out.push('');
340
+ const header = padded[0];
341
+ out.push(`| ${header.join(' | ')} |`);
342
+ out.push(`| ${header.map(() => '---').join(' | ')} |`);
343
+ padded.slice(1).forEach((r) => out.push(`| ${r.join(' | ')} |`));
344
+ out.push('');
345
+ };
346
+ const normalizeBlank = (s) => s.replace(/\n{3,}/g, '\n\n');
347
+ //# sourceMappingURL=basic-renderer.js.map