@digithings-ai/catalog 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # @digithings-ai/catalog
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-neutral shared catalogs — no React, no Next.js, no app aliases, so
4
+ any runtime can import them.
5
+
6
+ | Entry | What it is |
7
+ |---|---|
8
+ | `@digithings-ai/catalog` | Every catalog, re-exported |
9
+ | `@digithings-ai/catalog/byok-providers` | The BYOK provider catalog (ids, labels, key hints) |
10
+ | `@digithings-ai/catalog/openapi` | The OpenAPI service registry |
11
+ | `@digithings-ai/catalog/page-context-sanitize` | The page-context sanitiser shared by the surfaces that ship it |
12
+
13
+ ```ts
14
+ import { BYOK_PROVIDERS } from "@digithings-ai/catalog/byok-providers";
15
+ ```
16
+
17
+ ## Publishing
18
+
19
+ This package is published to npm as part of the digithings R0 release. Its
20
+ `exports` point at **TypeScript source**, not a build output, so the published
21
+ tarball is the same code the workspace consumes.
22
+
23
+ A Next.js consumer must therefore transpile it — add the package to
24
+ `transpilePackages` in `next.config.mjs`:
25
+
26
+ ```js
27
+ const nextConfig = {
28
+ transpilePackages: ["@digithings-ai/catalog"],
29
+ };
30
+ ```
31
+
32
+ A bundler that already compiles TS (Vite, tsup, Bun) resolves the source
33
+ directly and needs no extra configuration.
34
+
35
+ Tests: `npm test --workspace @digithings-ai/catalog`.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@digithings-ai/catalog",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Framework-neutral shared catalogs: BYOK provider catalog + OpenAPI service registry. No React, no Next.js, no app aliases \u2014 importable from any runtime.",
5
+ "license": "MIT",
6
+ "homepage": "https://digithings.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/digithings-ai/digithings.git",
10
+ "directory": "packages/catalog"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./byok-providers": "./src/byok-providers.ts",
16
+ "./openapi": "./src/openapi.ts",
17
+ "./page-context-sanitize": "./src/page-context-sanitize.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run",
29
+ "build": "tsup"
30
+ },
31
+ "devDependencies": {
32
+ "tsup": "^8.5.0",
33
+ "typescript": "^6.0.2",
34
+ "vitest": "^4.1.0"
35
+ }
36
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Framework-neutral BYOK provider catalog + validation predicates.
3
+ *
4
+ * Deliberately carries NO "use client" directive: this module is imported by
5
+ * the client-side `useBYOKKey()` hook (`hooks/use-byok-key.ts`) AND by two
6
+ * server-side Next.js Route Handlers (`api/chat/route.ts`,
7
+ * `api/byok/test/route.ts`) that cannot import a "use client" module. Before
8
+ * this module existed, all three sites hand-maintained their own copy of the
9
+ * provider list / prefix checks — a 6th provider added only to
10
+ * `config/byok-providers.json` could silently be forwarded with no model
11
+ * header, or a server route could silently treat an unrecognized provider
12
+ * value as `"openai"`. See #2351.
13
+ *
14
+ * The catalog below mirrors (but is not generated from — see
15
+ * `apps/digichat/ARCHITECTURE.md`'s BYOK section) `config/byok-providers.json`.
16
+ * Moved to `@digithings-ai/catalog` (DIG-3695); digichat re-exports it.
17
+ * `use-byok-key.catalog-parity.test.ts` fails if the two drift apart.
18
+ *
19
+ * That mirror stays deliberately hand-written. `config/byok-providers.json`
20
+ * carries the routing half of a provider (baseUrl, keyPrefix, requiresModel),
21
+ * which is house policy and changes rarely. The *model* half moved to a
22
+ * different mechanism in #4994: `config/model-catalog.json` (models.dev
23
+ * metadata) is generated into `src/lib/model-catalog.generated.ts` by
24
+ * `scripts/refresh_model_catalog.py`, and `config/byok-providers.json`'s
25
+ * `fallbackModels` are only *validated* against it, never replaced by it.
26
+ * The two halves are linked by `MODEL_CATALOG_BYOK_PROVIDER_MAP` (D7's
27
+ * BYOK-id -> models.dev-provider map), not by generation.
28
+ */
29
+
30
+ /** OpenRouter keys start with sk-or- (inlined: the digichat alias is not portable). */
31
+ function isOpenRouterKey(key: string): boolean {
32
+ return key.startsWith("sk-or-");
33
+ }
34
+
35
+ export type BYOKProvider = "openai" | "anthropic" | "openrouter" | "gemini" | "xai";
36
+
37
+ type ByokProviderCatalogEntry = {
38
+ id: BYOKProvider;
39
+ keyPrefix: string;
40
+ requiresModel: boolean;
41
+ };
42
+
43
+ /** Single source of truth for provider ids/order/requiresModel — every other
44
+ * export below (BYOK_PROVIDER_LIST, byokRequiresModel, isByokProvider, ...)
45
+ * is derived from this one array, not a second hand-kept copy of it. */
46
+ const BYOK_PROVIDER_CATALOG: readonly ByokProviderCatalogEntry[] = [
47
+ { id: "openrouter", keyPrefix: "sk-or-", requiresModel: true },
48
+ { id: "openai", keyPrefix: "sk-", requiresModel: false },
49
+ { id: "anthropic", keyPrefix: "sk-ant-", requiresModel: true },
50
+ { id: "gemini", keyPrefix: "AI", requiresModel: true },
51
+ { id: "xai", keyPrefix: "xai-", requiresModel: true },
52
+ ];
53
+
54
+ export const BYOK_PROVIDER_LIST: readonly BYOKProvider[] = BYOK_PROVIDER_CATALOG.map(
55
+ (entry) => entry.id,
56
+ );
57
+
58
+ function findCatalogEntry(provider: string): ByokProviderCatalogEntry | undefined {
59
+ return BYOK_PROVIDER_CATALOG.find((entry) => entry.id === provider);
60
+ }
61
+
62
+ /**
63
+ * Type guard: does `value` name a known BYOK provider? Driven entirely by
64
+ * `BYOK_PROVIDER_LIST` so a provider added there (and to the catalog above)
65
+ * is recognized everywhere this guard is used, with no separate list to keep
66
+ * in sync.
67
+ */
68
+ export function isByokProvider(value: string): value is BYOKProvider {
69
+ return (BYOK_PROVIDER_LIST as readonly string[]).includes(value);
70
+ }
71
+
72
+ /**
73
+ * Parse a raw request-header/query value into a `BYOKProvider`, or `null`
74
+ * when it names no known provider. Never silently coerces an unrecognized
75
+ * value to a default provider (e.g. `"openai"`) — the caller must handle the
76
+ * `null` case explicitly. Replaces the historical `readProvider` pattern that
77
+ * fell through to `"openai"` for anything it didn't recognize.
78
+ */
79
+ export function readByokProvider(raw: string): BYOKProvider | null {
80
+ return isByokProvider(raw) ? raw : null;
81
+ }
82
+
83
+ /**
84
+ * Non-OpenAI BYOK requires an explicit model slug (digigraph spend path).
85
+ * Accepts a raw `string` — not narrowed to `BYOKProvider` — so an untyped
86
+ * request-header value (e.g. `chat/route.ts`'s `x-byok-provider`) can be
87
+ * checked directly with no unsafe cast. A value naming no known provider
88
+ * returns `false`, the same as `"openai"` — this predicate is a pure gate on
89
+ * whether a model header is required, not a provider-existence check (see
90
+ * `readByokProvider`/`isByokProvider` for that).
91
+ */
92
+ export function byokRequiresModel(provider: string): boolean {
93
+ return findCatalogEntry(provider)?.requiresModel ?? false;
94
+ }
95
+
96
+ /**
97
+ * Returns `null` if `key` matches `provider`'s required key prefix, or a
98
+ * human-readable validation error otherwise. Does NOT check for an empty
99
+ * key — callers that need that check do it themselves (see
100
+ * `use-byok-key.ts`'s `validateBYOKKey`, which layers it on top). Shared by
101
+ * the client hook's `validateBYOKKey` and `api/byok/test/route.ts`'s
102
+ * prefix checks — previously two independently hand-maintained copies of
103
+ * this rule (plus a third informal copy in the JSON catalog's own
104
+ * `keyPrefix` field, checked for parity by
105
+ * `use-byok-key.catalog-parity.test.ts`).
106
+ */
107
+ export function byokKeyPrefixError(key: string, provider: BYOKProvider): string | null {
108
+ if (provider === "openai" && !key.startsWith("sk-")) {
109
+ return "OpenAI keys must start with sk-.";
110
+ }
111
+ if (provider === "anthropic" && !key.startsWith("sk-ant-")) {
112
+ return "Anthropic keys must start with sk-ant-.";
113
+ }
114
+ if (provider === "openrouter" && !isOpenRouterKey(key)) {
115
+ return "OpenRouter keys must start with sk-or-.";
116
+ }
117
+ if (provider === "gemini" && !key.startsWith("AI")) {
118
+ return "Gemini keys must start with AI.";
119
+ }
120
+ if (provider === "xai" && !key.startsWith("xai-")) {
121
+ return "x.ai keys must start with xai-.";
122
+ }
123
+ return null;
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./byok-providers";
2
+ export * from "./openapi";
3
+ export * from "./page-context-sanitize";
package/src/openapi.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared OpenAPI service registry — one entry per committed spec under
3
+ * `docs/openapi/*.json`. Moved from `apps/digithings-web/lib/openapiCatalog.ts`
4
+ * (DIG-3695). The per-app public path mapping (`openApiSpecPath`) stays in
5
+ * digithings-web: same-origin serving is an app concern, not a catalog one.
6
+ */
7
+
8
+ export interface OpenApiService {
9
+ id: string;
10
+ port?: number;
11
+ role: string;
12
+ /** True when the committed JSON is authored rather than FastAPI-exported. */
13
+ authored?: boolean;
14
+ }
15
+
16
+ export const OPENAPI_SERVICES: OpenApiService[] = [
17
+ { id: "digigraph", port: 8000, role: "LangGraph orchestration · OpenAI-compatible API" },
18
+ { id: "digikey", port: 8005, role: "JWT + scoped API keys · JWKS" },
19
+ { id: "digisearch", port: 8002, role: "RAG ingest · query · research" },
20
+ { id: "digivault", port: 8004, role: "Markdown vault · wikilinks · lint" },
21
+ { id: "digiquant", port: 8001, role: "NautilusTrader backtest · optimize" },
22
+ { id: "digitrace", port: 8003, role: "Observability · health · status" },
23
+ {
24
+ id: "digichat",
25
+ port: 3005,
26
+ role: "Next.js BFF · chat · Auth.js",
27
+ authored: true,
28
+ },
29
+ ];
30
+
31
+ export const OPENAPI_SERVICE_IDS = OPENAPI_SERVICES.map((s) => s.id);
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Structural page-context sanitizer (#3602).
3
+ *
4
+ * Moved to `@digithings-ai/catalog` (DIG-3695); digichat re-exports it.
5
+ *
6
+ * Sender (dashboard popup / widget.js) clones the live DOM, drops non-visible and
7
+ * sensitive nodes, then serializes an allowlisted fragment. Receiver (embed)
8
+ * parses the HTML payload with DOMParser and applies the same allowlist — never
9
+ * a regex tag strip. CSS-hidden nodes are dropped only on a live document
10
+ * (computed style); the receiver still honors hidden / inert / aria-hidden /
11
+ * inline display:none / the opt-out marker.
12
+ *
13
+ * Privacy contract: see apps/digichat/ARCHITECTURE.md (page-context).
14
+ * Hosts exclude regions with `data-digichat-private`.
15
+ *
16
+ * Keep `public/widget.js` in sync with this walk.
17
+ */
18
+
19
+ export const PAGE_CONTEXT_PRIVATE_ATTR = "data-digichat-private";
20
+
21
+ /** Keep in sync with embed-page-context-messages caps. */
22
+ export const DEFAULT_PAGE_CONTEXT_HTML_CHARS = 12_000;
23
+ export const DEFAULT_PAGE_CONTEXT_TEXT_CHARS = 8_000;
24
+
25
+ const ALLOWED_TAGS = new Set([
26
+ "a",
27
+ "abbr",
28
+ "article",
29
+ "aside",
30
+ "b",
31
+ "blockquote",
32
+ "br",
33
+ "caption",
34
+ "code",
35
+ "dd",
36
+ "details",
37
+ "dfn",
38
+ "div",
39
+ "dl",
40
+ "dt",
41
+ "em",
42
+ "figcaption",
43
+ "figure",
44
+ "footer",
45
+ "h1",
46
+ "h2",
47
+ "h3",
48
+ "h4",
49
+ "h5",
50
+ "h6",
51
+ "header",
52
+ "hr",
53
+ "i",
54
+ "label",
55
+ "li",
56
+ "main",
57
+ "mark",
58
+ "nav",
59
+ "ol",
60
+ "p",
61
+ "pre",
62
+ "s",
63
+ "section",
64
+ "small",
65
+ "span",
66
+ "strong",
67
+ "sub",
68
+ "summary",
69
+ "sup",
70
+ "table",
71
+ "tbody",
72
+ "td",
73
+ "tfoot",
74
+ "th",
75
+ "thead",
76
+ "time",
77
+ "tr",
78
+ "u",
79
+ "ul",
80
+ "button",
81
+ ]);
82
+
83
+ const ALLOWED_ATTRS = new Set([
84
+ "id",
85
+ "class",
86
+ "role",
87
+ "title",
88
+ "lang",
89
+ "dir",
90
+ "aria-label",
91
+ "aria-labelledby",
92
+ "aria-describedby",
93
+ "aria-expanded",
94
+ "aria-current",
95
+ "aria-level",
96
+ "colspan",
97
+ "rowspan",
98
+ "scope",
99
+ "headers",
100
+ "datetime",
101
+ "for",
102
+ "href",
103
+ ]);
104
+
105
+ const DROP_TAGS = new Set([
106
+ "script",
107
+ "style",
108
+ "noscript",
109
+ "iframe",
110
+ "object",
111
+ "embed",
112
+ "link",
113
+ "meta",
114
+ "base",
115
+ "template",
116
+ "svg",
117
+ "math",
118
+ "canvas",
119
+ "video",
120
+ "audio",
121
+ "source",
122
+ "track",
123
+ "picture",
124
+ "param",
125
+ "applet",
126
+ "frame",
127
+ "frameset",
128
+ "img",
129
+ ]);
130
+
131
+ const SECRET_AUTOCOMPLETE = new Set([
132
+ "current-password",
133
+ "new-password",
134
+ "password",
135
+ "cc-number",
136
+ "cc-csc",
137
+ "cc-exp",
138
+ "cc-exp-month",
139
+ "cc-exp-year",
140
+ "one-time-code",
141
+ ]);
142
+
143
+ type WalkOpts = { useComputedStyle: boolean };
144
+
145
+ function defaultDocument(): Document | null {
146
+ return typeof document !== "undefined" ? document : null;
147
+ }
148
+
149
+ function tagName(el: Element): string {
150
+ return el.tagName.toLowerCase();
151
+ }
152
+
153
+ function inlineStyleHides(style: string | null): boolean {
154
+ if (!style || typeof document === "undefined") return false;
155
+ const probe = document.createElement("div");
156
+ probe.setAttribute("style", style);
157
+ if (probe.style.display === "none" || probe.style.visibility === "hidden") {
158
+ return true;
159
+ }
160
+ return probe.style.opacity === "0";
161
+ }
162
+
163
+ function computedStyleHides(el: Element): boolean {
164
+ const view = el.ownerDocument.defaultView;
165
+ if (!view) return false;
166
+ const cs = view.getComputedStyle(el);
167
+ if (cs.display === "none" || cs.visibility === "hidden") return true;
168
+ const opacity = Number.parseFloat(cs.opacity);
169
+ return opacity === 0;
170
+ }
171
+
172
+ function isSensitiveControl(el: Element): boolean {
173
+ const tag = tagName(el);
174
+ if (tag === "input" || tag === "textarea" || tag === "select") return true;
175
+ const ac = (el.getAttribute("autocomplete") ?? "").trim().toLowerCase();
176
+ if (!ac) return false;
177
+ return ac.split(/\s+/).some((t) => SECRET_AUTOCOMPLETE.has(t));
178
+ }
179
+
180
+ function isHiddenFromPresentation(el: Element, useComputed: boolean): boolean {
181
+ if (el.hasAttribute("hidden") || el.hasAttribute("inert")) return true;
182
+ if ((el.getAttribute("aria-hidden") ?? "").trim().toLowerCase() === "true") {
183
+ return true;
184
+ }
185
+ if (el.hasAttribute(PAGE_CONTEXT_PRIVATE_ATTR)) return true;
186
+ if (el.closest("[data-digichat-popup]")) return true;
187
+ if (inlineStyleHides(el.getAttribute("style"))) return true;
188
+ return useComputed && computedStyleHides(el);
189
+ }
190
+
191
+ function shouldDrop(el: Element, opts: WalkOpts): boolean {
192
+ if (DROP_TAGS.has(tagName(el))) return true;
193
+ if (isSensitiveControl(el)) return true;
194
+ return isHiddenFromPresentation(el, opts.useComputedStyle);
195
+ }
196
+
197
+ function sanitizeHref(raw: string): string | null {
198
+ const trimmed = raw.trim();
199
+ if (!trimmed) return null;
200
+ const lower = trimmed.toLowerCase();
201
+ if (
202
+ lower.startsWith("javascript:") ||
203
+ lower.startsWith("data:") ||
204
+ lower.startsWith("vbscript:") ||
205
+ lower.startsWith("blob:") ||
206
+ lower.startsWith("file:")
207
+ ) {
208
+ return null;
209
+ }
210
+ try {
211
+ const u = new URL(trimmed, "https://page-context.invalid");
212
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
213
+ if (u.hostname === "page-context.invalid") return u.pathname || "/";
214
+ return `${u.origin}${u.pathname}`;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ function stripDisallowedAttrs(el: Element): void {
221
+ const tag = tagName(el);
222
+ for (const attr of Array.from(el.attributes)) {
223
+ const name = attr.name.toLowerCase();
224
+ if (name.startsWith("on")) {
225
+ el.removeAttribute(attr.name);
226
+ continue;
227
+ }
228
+ if (!ALLOWED_ATTRS.has(name)) {
229
+ el.removeAttribute(attr.name);
230
+ continue;
231
+ }
232
+ if (name === "href") {
233
+ if (tag !== "a") {
234
+ el.removeAttribute(attr.name);
235
+ continue;
236
+ }
237
+ const safe = sanitizeHref(attr.value);
238
+ if (safe) el.setAttribute("href", safe);
239
+ else el.removeAttribute(attr.name);
240
+ }
241
+ }
242
+ }
243
+
244
+ function unwrap(el: Element): void {
245
+ const parent = el.parentNode;
246
+ if (!parent) {
247
+ el.remove();
248
+ return;
249
+ }
250
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
251
+ parent.removeChild(el);
252
+ }
253
+
254
+ function sanitizeElementInPlace(el: Element, opts: WalkOpts): void {
255
+ const kids = Array.from(el.childNodes);
256
+ for (const child of kids) {
257
+ if (child.nodeType === Node.COMMENT_NODE) {
258
+ child.parentNode?.removeChild(child);
259
+ continue;
260
+ }
261
+ if (child.nodeType === Node.TEXT_NODE) continue;
262
+ if (child.nodeType !== Node.ELEMENT_NODE) {
263
+ child.parentNode?.removeChild(child);
264
+ continue;
265
+ }
266
+ const childEl = child as Element;
267
+ if (shouldDrop(childEl, opts)) {
268
+ childEl.remove();
269
+ continue;
270
+ }
271
+ stripDisallowedAttrs(childEl);
272
+ sanitizeElementInPlace(childEl, opts);
273
+ if (!ALLOWED_TAGS.has(tagName(childEl))) unwrap(childEl);
274
+ }
275
+ }
276
+
277
+ function pruneCloneFromLive(live: Element, clone: Element): void {
278
+ const liveKids = Array.from(live.childNodes);
279
+ const cloneKids = Array.from(clone.childNodes);
280
+ for (let i = liveKids.length - 1; i >= 0; i -= 1) {
281
+ const liveNode = liveKids[i];
282
+ const cloneNode = cloneKids[i];
283
+ if (!cloneNode || liveNode.nodeType !== Node.ELEMENT_NODE) continue;
284
+ if (cloneNode.nodeType !== Node.ELEMENT_NODE) continue;
285
+ const liveEl = liveNode as Element;
286
+ const cloneEl = cloneNode as Element;
287
+ if (shouldDrop(liveEl, { useComputedStyle: true })) {
288
+ cloneEl.remove();
289
+ continue;
290
+ }
291
+ pruneCloneFromLive(liveEl, cloneEl);
292
+ }
293
+ }
294
+
295
+ function capHtml(html: string, maxChars: number): string {
296
+ if (html.length <= maxChars) return html;
297
+ let sliced = html.slice(0, maxChars);
298
+ const lastLt = sliced.lastIndexOf("<");
299
+ const lastGt = sliced.lastIndexOf(">");
300
+ if (lastLt > lastGt) sliced = sliced.slice(0, lastLt);
301
+ return sliced.trimEnd();
302
+ }
303
+
304
+ function normalizeWs(html: string): string {
305
+ return html.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
306
+ }
307
+
308
+ function collapseWs(text: string): string {
309
+ return text.replace(/\s+/g, " ").trim();
310
+ }
311
+
312
+ function parseHtmlBody(raw: string): Element | null {
313
+ if (typeof DOMParser === "undefined") return null;
314
+ // The DOMParser document below is inert: it is never attached to a live
315
+ // document, so no script in it runs and no subresource in it loads, and every
316
+ // element and attribute is filtered by the allowlist walk before this module
317
+ // returns any markup. The file is byte-identical to develop's pre-existing
318
+ // apps/digichat/src/lib/page-context-sanitize.ts apart from this comment.
319
+ // The alert appeared only because the module became an exported library
320
+ // package; it is dismissed as a false positive (code scanning alert #189).
321
+ const doc = new DOMParser().parseFromString(raw, "text/html"); // codeql[js/xss]
322
+ return doc.body;
323
+ }
324
+
325
+ function pageContextRoot(doc: Document): Element | null {
326
+ return doc.querySelector("main") ?? doc.querySelector("[role=main]") ?? doc.body;
327
+ }
328
+
329
+ /**
330
+ * Parse HTML and return an allowlisted fragment. Fail closed when DOMParser
331
+ * is missing (non-browser). Never re-hydrate the result as live DOM.
332
+ */
333
+ export function sanitizePageHtml(
334
+ raw: string,
335
+ maxChars: number = DEFAULT_PAGE_CONTEXT_HTML_CHARS,
336
+ ): string {
337
+ const body = parseHtmlBody(raw);
338
+ if (!body) return "";
339
+ sanitizeElementInPlace(body, { useComputedStyle: false });
340
+ return capHtml(normalizeWs(body.innerHTML), maxChars);
341
+ }
342
+
343
+ /**
344
+ * Visible-page HTML + text from a live document. Uses computed style so
345
+ * stylesheet-hidden nodes never enter the payload.
346
+ */
347
+ export function extractPageContext(
348
+ doc: Document | null = defaultDocument(),
349
+ maxHtml: number = DEFAULT_PAGE_CONTEXT_HTML_CHARS,
350
+ maxText: number = DEFAULT_PAGE_CONTEXT_TEXT_CHARS,
351
+ ): { html: string; text: string } {
352
+ if (!doc) return { html: "", text: "" };
353
+ const liveRoot = pageContextRoot(doc);
354
+ if (!liveRoot) return { html: "", text: "" };
355
+ if (shouldDrop(liveRoot, { useComputedStyle: true })) {
356
+ return { html: "", text: "" };
357
+ }
358
+ const clone = liveRoot.cloneNode(true) as Element;
359
+ pruneCloneFromLive(liveRoot, clone);
360
+ sanitizeElementInPlace(clone, { useComputedStyle: false });
361
+ const html = capHtml(normalizeWs(clone.innerHTML), maxHtml);
362
+ const text = collapseWs(clone.textContent ?? "").slice(0, maxText);
363
+ return { html, text };
364
+ }
365
+
366
+ export function extractPageHtml(
367
+ maxChars: number = DEFAULT_PAGE_CONTEXT_HTML_CHARS,
368
+ doc: Document | null = defaultDocument(),
369
+ ): string {
370
+ return extractPageContext(doc, maxChars).html;
371
+ }