notion-static-exporter 1.0.2
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 +11 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/dist/assets.d.ts +47 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/browser.d.ts +10 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/cache.d.ts +54 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/challenge.d.ts +23 -0
- package/dist/challenge.d.ts.map +1 -0
- package/dist/cli.js +5595 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/crawl.d.ts +18 -0
- package/dist/crawl.d.ts.map +1 -0
- package/dist/github.d.ts +17 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/index.cjs +49 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/pwa.d.ts +7 -0
- package/dist/pwa.d.ts.map +1 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/scrape.d.ts +30 -0
- package/dist/scrape.d.ts.map +1 -0
- package/dist/snapshot.d.ts +36 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/ui.d.ts +30 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/urls.d.ts +21 -0
- package/dist/urls.d.ts.map +1 -0
- package/examples/sync-notion.yml +78 -0
- package/package.json +65 -0
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type ExporterConfig = {
|
|
2
|
+
url?: string;
|
|
3
|
+
out?: string;
|
|
4
|
+
keepCname?: boolean;
|
|
5
|
+
maxPages?: number;
|
|
6
|
+
delayMs?: number;
|
|
7
|
+
maxRetries?: number;
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
userDataDir?: string;
|
|
10
|
+
workflow?: string;
|
|
11
|
+
repo?: string;
|
|
12
|
+
ref?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function loadConfig(projectRoot: string): ExporterConfig;
|
|
15
|
+
export declare const exampleConfigJson = "{\n \"url\": \"https://almond-brownie-c82.notion.site/Elementary-3b515e0e4a098053bb74c985cebfd777\",\n \"out\": \".\",\n \"keepCname\": true,\n \"maxPages\": 0,\n \"delayMs\": 1500,\n \"concurrency\": 3,\n \"maxRetries\": 3\n}\n";
|
|
16
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GAAG;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAOF,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAY9D;AAED,eAAO,MAAM,iBAAiB,gPAS7B,CAAC"}
|
package/dist/crawl.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Page } from "puppeteer";
|
|
2
|
+
export type CrawlState = {
|
|
3
|
+
queue: string[];
|
|
4
|
+
visited: Set<string>;
|
|
5
|
+
/** pageKey → absolute page URL used for crawl */
|
|
6
|
+
pages: Map<string, string>;
|
|
7
|
+
};
|
|
8
|
+
export declare function createCrawl(rootUrl: string): CrawlState;
|
|
9
|
+
export declare function enqueueIfNew(state: CrawlState, rawUrl: string, baseUrl: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Collect every same-site Notion page link from the rendered DOM.
|
|
12
|
+
* Aggressive: anchors, role=link, and collection views.
|
|
13
|
+
*/
|
|
14
|
+
export declare function collectSameSiteLinks(page: Page, currentUrl: string): Promise<string[]>;
|
|
15
|
+
export declare function waitForNotionContent(page: Page, opts?: {
|
|
16
|
+
deep?: boolean;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
//# sourceMappingURL=crawl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crawl.d.ts","sourceRoot":"","sources":["../src/crawl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAItC,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrB,iDAAiD;IACjD,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC5B,CAAC;AAEF,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAavD;AAED,wBAAgB,YAAY,CAC1B,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAST;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,IAAI,EACV,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC,CAqEnB;AAED,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,IAAI,EACV,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5B,OAAO,CAAC,IAAI,CAAC,CAWf"}
|
package/dist/github.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trigger a GitHub Actions workflow_dispatch.
|
|
3
|
+
* Needs GITHUB_TOKEN or GH_TOKEN with `actions: write` on the repo.
|
|
4
|
+
*/
|
|
5
|
+
export type TriggerOptions = {
|
|
6
|
+
repo: string;
|
|
7
|
+
workflow: string;
|
|
8
|
+
ref?: string;
|
|
9
|
+
token?: string;
|
|
10
|
+
};
|
|
11
|
+
export type TriggerResult = {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
status: number;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function triggerWorkflow(opts: TriggerOptions): Promise<TriggerResult>;
|
|
17
|
+
//# sourceMappingURL=github.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../src/github.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,wBAAsB,eAAe,CACnC,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,aAAa,CAAC,CAuDxB"}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
function __accessProp(key) {
|
|
6
|
+
return this[key];
|
|
7
|
+
}
|
|
8
|
+
var __toCommonJS = (from) => {
|
|
9
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
10
|
+
if (entry)
|
|
11
|
+
return entry;
|
|
12
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (var key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(entry, key))
|
|
16
|
+
__defProp(entry, key, {
|
|
17
|
+
get: __accessProp.bind(from, key),
|
|
18
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
__moduleCache.set(from, entry);
|
|
22
|
+
return entry;
|
|
23
|
+
};
|
|
24
|
+
var __moduleCache;
|
|
25
|
+
var __returnValue = (v) => v;
|
|
26
|
+
function __exportSetter(name, newValue) {
|
|
27
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
28
|
+
}
|
|
29
|
+
var __export = (target, all) => {
|
|
30
|
+
for (var name in all)
|
|
31
|
+
__defProp(target, name, {
|
|
32
|
+
get: all[name],
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
set: __exportSetter.bind(all, name)
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/index.ts
|
|
40
|
+
var exports_src = {};
|
|
41
|
+
__export(exports_src, {
|
|
42
|
+
triggerWorkflow: () => import_github.triggerWorkflow,
|
|
43
|
+
syncNotionSite: () => import_scrape.syncNotionSite,
|
|
44
|
+
pageKey: () => import_urls.pageKey,
|
|
45
|
+
normalizePageUrl: () => import_urls.normalizePageUrl,
|
|
46
|
+
loadConfig: () => import_config.loadConfig,
|
|
47
|
+
extractPageId: () => import_urls.extractPageId
|
|
48
|
+
});
|
|
49
|
+
module.exports = __toCommonJS(exports_src);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { syncNotionSite, type SyncOptions, type SyncResult } from "./scrape.ts";
|
|
2
|
+
export { triggerWorkflow, type TriggerOptions, type TriggerResult } from "./github.ts";
|
|
3
|
+
export { extractPageId, normalizePageUrl, pageKey } from "./urls.ts";
|
|
4
|
+
export { loadConfig, type ExporterConfig } from "./config.ts";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
package/dist/pwa.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pwa.d.ts","sourceRoot":"","sources":["../src/pwa.ts"],"names":[],"mappings":"AAiCA,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7C;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAyDnB"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline runtime injected into every scraped page.
|
|
3
|
+
* Collection tabs/filter/search/sort, toggles, TOC, image lightbox, media layout.
|
|
4
|
+
*/
|
|
5
|
+
export declare const RUNTIME_JS = "(() => {\n const $ = (s, r=document) => r.querySelector(s);\n const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));\n\n const CLOSE_SVG =\n '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M3.22 3.22a.75.75 0 0 1 1.06 0L8 6.94l3.72-3.72a.75.75 0 1 1 1.06 1.06L9.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L8 9.06l-3.72 3.72a.75.75 0 0 1-1.06-1.06L6.94 8 3.22 4.28a.75.75 0 0 1 0-1.06z\"/></svg>';\n\n function syncViewport() {\n const root = document.documentElement;\n const body = document.body;\n if (!root || !body) return;\n try {\n root.style.setProperty(\"--full-viewport-height\", \"100dvh\");\n for (const el of $$(\".notion-frame, main.notion-frame, .notion-cursor-listener\")) {\n if (!el || !el.style) continue;\n if (el.style.width && /px$/.test(el.style.width)) {\n el.style.width = \"100%\";\n el.style.maxWidth = \"100%\";\n }\n }\n body.style.width = \"100%\";\n body.style.maxWidth = \"100%\";\n } catch (_) {\n /* ignore \u2014 can race during fullscreen / lightbox */\n }\n }\n\n function hidePromos() {\n const re = /get notion free|log in|sign up|try notion|download app/i;\n for (const el of $$(\".notion-topbar [role='button'], .notion-topbar a\")) {\n const t = (el.textContent || \"\").replace(/\\s+/g, \" \").trim();\n const a = el.getAttribute(\"aria-label\") || \"\";\n if (re.test(t) || re.test(a)) {\n const wrap = el.closest(\".xjp7ctv\") || el;\n wrap.style.display = \"none\";\n }\n }\n for (const el of $$(\".notion-topbar [role='button']\")) {\n if (el.closest(\".notion-collection_view-block, .notion-collection_view_page-block\")) continue;\n const label = (el.getAttribute(\"aria-label\") || \"\").toLowerCase();\n const svg = el.querySelector(\"svg.magnifyingGlass, svg.magnifyingGlassSmall\");\n if (label === \"search\" || (svg && !label.includes(\"more\"))) {\n const wrap = el.closest(\".xjp7ctv\") || el;\n wrap.style.display = \"none\";\n }\n }\n layoutTopbar();\n }\n\n /** Keep breadcrumbs left, \u22EE More actions pinned to the right. */\n function layoutTopbar() {\n for (const topbar of $$(\".notion-topbar\")) {\n const row =\n topbar.querySelector(\":scope > div\") || topbar;\n row.style.display = \"flex\";\n row.style.justifyContent = \"space-between\";\n row.style.alignItems = \"center\";\n row.style.width = \"100%\";\n row.style.maxWidth = \"100%\";\n row.style.boxSizing = \"border-box\";\n\n const more =\n topbar.querySelector(\"[aria-label='More actions']\") ||\n topbar.querySelector(\"svg.ellipsis\")?.closest(\"[role='button']\");\n if (!more) continue;\n\n let right = topbar.querySelector(\"[data-nsp-topbar-right]\");\n if (!right) {\n right = document.createElement(\"div\");\n right.setAttribute(\"data-nsp-topbar-right\", \"1\");\n Object.assign(right.style, {\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"flex-end\",\n marginInlineStart: \"auto\",\n flexShrink: \"0\",\n gap: \"2px\",\n });\n row.appendChild(right);\n }\n const wrap = more.closest(\".xjp7ctv\") || more;\n if (wrap.parentElement !== right) right.appendChild(wrap);\n wrap.style.display = \"\";\n more.style.display = \"\";\n }\n // Remove any leftover install CTA\n for (const el of $$(\"[data-nsp-install]\")) el.remove();\n }\n\n function wireOriginalMenu() {\n const original =\n document.documentElement.getAttribute(\"data-nsp-original-url\") || \"\";\n if (!original || original.startsWith(\"./\")) return;\n\n const btn =\n $(\".notion-topbar [aria-label='More actions']\") ||\n $(\".notion-topbar svg.ellipsis\")?.closest(\"[role='button']\");\n if (!btn || btn.dataset.nspWired) return;\n btn.dataset.nspWired = \"1\";\n\n let pop = null;\n const close = () => {\n if (pop) { pop.remove(); pop = null; }\n btn.setAttribute(\"aria-expanded\", \"false\");\n };\n\n btn.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n if (pop) { close(); return; }\n pop = document.createElement(\"div\");\n pop.setAttribute(\"data-nsp-menu\", \"1\");\n Object.assign(pop.style, {\n position: \"absolute\",\n top: \"100%\",\n right: \"0\",\n marginTop: \"6px\",\n minWidth: \"220px\",\n maxWidth: \"min(360px, 90vw)\",\n padding: \"8px\",\n borderRadius: \"10px\",\n background: \"var(--c-bacPri, #191919)\",\n color: \"var(--c-texPri, #fff)\",\n boxShadow: \"0 8px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(255,255,255,.08)\",\n zIndex: \"10000\",\n fontSize: \"14px\",\n });\n const link = document.createElement(\"a\");\n link.href = original;\n link.target = \"_blank\";\n link.rel = \"noopener noreferrer\";\n link.textContent = \"Open original page\";\n Object.assign(link.style, {\n display: \"block\",\n padding: \"8px 10px\",\n borderRadius: \"6px\",\n color: \"inherit\",\n textDecoration: \"none\",\n fontWeight: \"500\",\n });\n link.onmouseenter = () => { link.style.background = \"rgba(255,255,255,.08)\"; };\n link.onmouseleave = () => { link.style.background = \"transparent\"; };\n const url = document.createElement(\"div\");\n url.textContent = original;\n Object.assign(url.style, {\n padding: \"4px 10px 8px\",\n opacity: \"0.55\",\n fontSize: \"12px\",\n wordBreak: \"break-all\",\n lineHeight: \"1.35\",\n });\n pop.appendChild(link);\n pop.appendChild(url);\n const host = btn.closest(\".xjp7ctv\") || btn.parentElement || btn;\n if (getComputedStyle(host).position === \"static\") host.style.position = \"relative\";\n host.appendChild(pop);\n btn.setAttribute(\"aria-expanded\", \"true\");\n });\n\n document.addEventListener(\"click\", (e) => {\n if (!pop) return;\n if (pop.contains(e.target) || btn.contains(e.target)) return;\n close();\n });\n }\n\n function filterItems(root, q) {\n const query = (q || \"\").trim().toLowerCase();\n for (const item of $$(\n \".notion-collection-item, .notion-gallery-view .notion-page-block, .notion-table-view-row, .notion-list-view .notion-page-block, .notion-board-view .notion-page-block, .notion-calendar-view .notion-page-block\",\n root,\n )) {\n const text = (item.textContent || \"\").toLowerCase();\n item.style.display = !query || text.includes(query) ? \"\" : \"none\";\n }\n }\n\n function themeHost() {\n return (\n document.querySelector(\".notion-dark-theme\") ||\n document.querySelector(\".notion-light-theme\") ||\n document.querySelector(\".notion-app-inner\") ||\n document.body\n );\n }\n\n function closePanel(root) {\n for (const el of document.querySelectorAll(\n \"[data-nsp-panel], [data-nsp-panel-backdrop]\",\n )) {\n el.remove();\n }\n if (root) filterItems(root, \"\");\n }\n\n function openFilterPanel(root, anchor, placeholder) {\n closePanel(root);\n const mobile = window.matchMedia(\"(max-width: 640px)\").matches;\n const host = themeHost();\n\n const backdrop = document.createElement(\"div\");\n backdrop.setAttribute(\"data-nsp-panel-backdrop\", \"1\");\n Object.assign(backdrop.style, {\n position: \"fixed\",\n inset: \"0\",\n zIndex: \"40000\",\n background: mobile ? \"rgba(0,0,0,.45)\" : \"transparent\",\n });\n backdrop.addEventListener(\"click\", () => {\n closePanel(root);\n anchor.setAttribute(\"aria-expanded\", \"false\");\n });\n\n const panel = document.createElement(\"div\");\n panel.setAttribute(\"data-nsp-panel\", \"1\");\n panel.setAttribute(\"role\", \"dialog\");\n panel.setAttribute(\"aria-label\", placeholder || \"Filter\");\n\n const basePanel = {\n position: \"fixed\",\n zIndex: \"40001\",\n padding: mobile ? \"16px 16px 20px\" : \"12px 12px 14px\",\n borderRadius: mobile ? \"16px 16px 12px 12px\" : \"10px\",\n background: \"var(--c-bacPri, #191919)\",\n color: \"var(--c-texPri, #fff)\",\n boxShadow: \"0 8px 28px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.1)\",\n boxSizing: \"border-box\",\n };\n\n if (mobile) {\n Object.assign(panel.style, basePanel, {\n left: \"0\",\n right: \"0\",\n bottom: \"0\",\n width: \"100%\",\n maxWidth: \"100vw\",\n paddingBottom: \"max(20px, env(safe-area-inset-bottom))\",\n });\n } else {\n const r = anchor.getBoundingClientRect();\n const width = 260;\n let left = Math.min(r.right - width, window.innerWidth - width - 12);\n left = Math.max(12, left);\n let top = r.bottom + 8;\n if (top + 140 > window.innerHeight) {\n top = Math.max(12, r.top - 140);\n }\n Object.assign(panel.style, basePanel, {\n top: `${top}px`,\n left: `${left}px`,\n width: `${width}px`,\n minWidth: \"220px\",\n maxWidth: \"min(360px, calc(100vw - 24px))\",\n });\n }\n\n const head = document.createElement(\"div\");\n Object.assign(head.style, {\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"space-between\",\n marginBottom: \"10px\",\n gap: \"8px\",\n });\n const title = document.createElement(\"div\");\n title.textContent = placeholder || \"Filter\";\n Object.assign(title.style, {\n fontSize: \"13px\",\n fontWeight: \"600\",\n opacity: \"0.85\",\n });\n const closeBtn = document.createElement(\"button\");\n closeBtn.type = \"button\";\n closeBtn.setAttribute(\"aria-label\", \"Close\");\n closeBtn.innerHTML = CLOSE_SVG;\n Object.assign(closeBtn.style, {\n appearance: \"none\",\n border: \"none\",\n background: \"transparent\",\n color: \"inherit\",\n cursor: \"pointer\",\n padding: \"8px\",\n borderRadius: \"8px\",\n display: \"flex\",\n opacity: \"0.7\",\n minWidth: \"40px\",\n minHeight: \"40px\",\n alignItems: \"center\",\n justifyContent: \"center\",\n });\n closeBtn.onmouseenter = () => {\n closeBtn.style.opacity = \"1\";\n closeBtn.style.background = \"rgba(255,255,255,.08)\";\n };\n closeBtn.onmouseleave = () => {\n closeBtn.style.opacity = \"0.7\";\n closeBtn.style.background = \"transparent\";\n };\n closeBtn.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n closePanel(root);\n anchor.setAttribute(\"aria-expanded\", \"false\");\n });\n head.appendChild(title);\n head.appendChild(closeBtn);\n\n const wrap = document.createElement(\"div\");\n Object.assign(wrap.style, {\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n });\n const input = document.createElement(\"input\");\n input.type = \"search\";\n input.placeholder = placeholder || \"Filter\u2026\";\n input.setAttribute(\"data-nsp-filter-input\", \"1\");\n input.setAttribute(\"enterkeyhint\", \"search\");\n Object.assign(input.style, {\n flex: \"1\",\n padding: mobile ? \"14px 16px\" : \"8px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255,255,255,0.15)\",\n background: \"rgba(255,255,255,0.04)\",\n color: \"inherit\",\n fontSize: mobile ? \"16px\" : \"14px\", // 16px avoids iOS zoom\n width: \"100%\",\n minWidth: \"0\",\n outline: \"none\",\n boxSizing: \"border-box\",\n });\n wrap.appendChild(input);\n panel.appendChild(head);\n panel.appendChild(wrap);\n\n host.appendChild(backdrop);\n host.appendChild(panel);\n anchor.setAttribute(\"aria-expanded\", \"true\");\n\n // Keep desktop panel on-screen when keyboard/orientation changes\n const place = () => {\n if (mobile || !panel.isConnected) return;\n const r = anchor.getBoundingClientRect();\n const width = panel.offsetWidth || 260;\n let left = Math.min(r.right - width, window.innerWidth - width - 12);\n left = Math.max(12, left);\n let top = r.bottom + 8;\n if (top + panel.offsetHeight > window.innerHeight - 12) {\n top = Math.max(12, r.top - panel.offsetHeight - 8);\n }\n panel.style.top = `${top}px`;\n panel.style.left = `${left}px`;\n };\n window.addEventListener(\"resize\", place, { passive: true });\n window.addEventListener(\"scroll\", place, { passive: true, capture: true });\n\n const cleanup = () => {\n window.removeEventListener(\"resize\", place);\n window.removeEventListener(\"scroll\", place, true);\n };\n const obs = new MutationObserver(() => {\n if (!panel.isConnected) {\n cleanup();\n obs.disconnect();\n }\n });\n obs.observe(document.documentElement, { childList: true, subtree: true });\n\n input.focus();\n input.addEventListener(\"input\", () => filterItems(root, input.value));\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\") {\n closePanel(root);\n anchor.setAttribute(\"aria-expanded\", \"false\");\n }\n });\n }\n\n function openSearchInline(root, btn) {\n let wrap = root.querySelector(\"[data-nsp-search-wrap]\");\n if (wrap) {\n const existing = wrap.querySelector(\"input\");\n // Re-clicking Search while open: clear if filled, otherwise close\n if (existing && existing.value.trim()) {\n existing.value = \"\";\n filterItems(root, \"\");\n existing.focus();\n return;\n }\n wrap.remove();\n filterItems(root, \"\");\n btn.style.display = \"\";\n return;\n }\n btn.style.display = \"none\";\n wrap = document.createElement(\"div\");\n wrap.setAttribute(\"data-nsp-search-wrap\", \"1\");\n Object.assign(wrap.style, {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: \"4px\",\n marginLeft: \"4px\",\n });\n const input = document.createElement(\"input\");\n input.type = \"search\";\n input.placeholder = \"Search\u2026\";\n input.setAttribute(\"data-nsp-filter-input\", \"1\");\n input.setAttribute(\"enterkeyhint\", \"search\");\n Object.assign(input.style, {\n padding: \"4px 8px\",\n borderRadius: \"6px\",\n border: \"1px solid rgba(255,255,255,0.15)\",\n background: \"transparent\",\n color: \"inherit\",\n fontSize: \"14px\",\n minWidth: \"140px\",\n maxWidth: \"min(220px, 50vw)\",\n outline: \"none\",\n });\n const closeBtn = document.createElement(\"button\");\n closeBtn.type = \"button\";\n closeBtn.setAttribute(\"aria-label\", \"Clear search\");\n closeBtn.innerHTML = CLOSE_SVG;\n Object.assign(closeBtn.style, {\n appearance: \"none\",\n border: \"none\",\n background: \"transparent\",\n color: \"inherit\",\n cursor: \"pointer\",\n padding: \"4px\",\n borderRadius: \"6px\",\n display: \"flex\",\n opacity: \"0.75\",\n minWidth: \"32px\",\n minHeight: \"32px\",\n alignItems: \"center\",\n justifyContent: \"center\",\n });\n const close = () => {\n wrap.remove();\n filterItems(root, \"\");\n btn.style.display = \"\";\n };\n const clearOrClose = () => {\n // First click clears text; second click (when empty) closes search\n if (input.value.trim()) {\n input.value = \"\";\n filterItems(root, \"\");\n input.focus();\n closeBtn.setAttribute(\"aria-label\", \"Close search\");\n return;\n }\n close();\n };\n closeBtn.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n clearOrClose();\n });\n input.addEventListener(\"input\", () => {\n filterItems(root, input.value);\n closeBtn.setAttribute(\n \"aria-label\",\n input.value.trim() ? \"Clear search\" : \"Close search\",\n );\n });\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n clearOrClose();\n }\n });\n wrap.appendChild(input);\n wrap.appendChild(closeBtn);\n (btn.parentElement || root).appendChild(wrap);\n input.focus();\n }\n\n function wireCollectionSearch(root) {\n const btn = root.querySelector('[aria-label=\"Search\"]');\n if (!btn || btn.dataset.nspWired) return;\n btn.dataset.nspWired = \"1\";\n btn.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n openSearchInline(root, btn);\n });\n }\n\n function wireCollectionFilter(root) {\n const btn =\n root.querySelector(\".notion-collection-filter\") ||\n root.querySelector('[aria-label=\"Filter\"]');\n if (!btn || btn.dataset.nspWired) return;\n btn.dataset.nspWired = \"1\";\n btn.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n if (document.querySelector(\"[data-nsp-panel]\")) {\n closePanel(root);\n btn.setAttribute(\"aria-expanded\", \"false\");\n return;\n }\n openFilterPanel(root, btn, \"Filter\u2026\");\n });\n }\n\n function wireCollectionSort(root) {\n const btn = root.querySelector(\".notion-collection-sort, [aria-label='Sort']\");\n if (!btn || btn.dataset.nspWired) return;\n btn.dataset.nspWired = \"1\";\n let asc = true;\n btn.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n const body = root.querySelector(\".notion-collection-view-body\") || root;\n const grid =\n body.querySelector(\".notion-gallery-view > div > div[style*='grid']\") ||\n body.querySelector(\".notion-gallery-view div[style*='grid-template']\") ||\n body.querySelector(\".notion-table-view\");\n const items = $$(\".notion-collection-item, .notion-table-view-row\", grid || body);\n if (!items.length) return;\n const parent = items[0].parentElement;\n if (!parent) return;\n const sorted = items.slice().sort((a, b) => {\n const ta = (a.textContent || \"\").trim().toLowerCase();\n const tb = (b.textContent || \"\").trim().toLowerCase();\n return asc ? ta.localeCompare(tb) : tb.localeCompare(ta);\n });\n asc = !asc;\n for (const it of sorted) parent.appendChild(it);\n btn.setAttribute(\"data-nsp-active\", \"1\");\n });\n }\n\n function tabLabel(el) {\n return (el.textContent || \"\").replace(/\\s+/g, \" \").trim();\n }\n\n function collectionTabs(root) {\n const tablist = root.querySelector('[role=\"tablist\"]');\n const scope = tablist || root;\n const buttons = $$(\".notion-collection-view-tab-button\", scope);\n if (buttons.length) return buttons;\n const raw = $$('[role=\"tab\"], .notion-collection-view-tab', scope);\n return raw.filter((t) => !t.closest(\".notion-collection-view-tab-button\"));\n }\n\n function styleTab(tab, selected) {\n const targets = [tab, ...$$('[role=\"tab\"], .notion-collection-view-tab', tab)];\n for (const el of targets) {\n el.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n if (selected) {\n el.setAttribute(\"data-nsp-active\", \"1\");\n el.style.background = \"var(--ca-graBacSecTra, rgba(255,255,255,.1))\";\n el.style.color = \"var(--c-texPri, inherit)\";\n const svg = el.querySelector(\"svg\");\n if (svg) svg.style.fill = \"var(--c-texPri, currentColor)\";\n } else {\n el.removeAttribute(\"data-nsp-active\");\n el.style.background = \"\";\n const svg = el.querySelector(\"svg\");\n if (svg) svg.style.fill = \"var(--c-texSec, currentColor)\";\n }\n }\n }\n\n function findCapturedView(views, tab, index) {\n const label = tabLabel(tab).toLowerCase();\n const byLabel = views.tabs.find((t) => {\n const L = (t.label || \"\").toLowerCase();\n return L === label || label.includes(L) || L.includes(label);\n });\n return byLabel || views.tabs[index];\n }\n\n function defaultTabIndex(views, tabs) {\n const prefer = (views && views.defaultLabel) || \"Gallery view\";\n let idx = tabs.findIndex((t) =>\n tabLabel(t).toLowerCase() === prefer.toLowerCase(),\n );\n if (idx < 0) {\n idx = tabs.findIndex((t) => /gallery/i.test(tabLabel(t)));\n }\n if (idx < 0) idx = (views && typeof views.defaultIndex === \"number\") ? views.defaultIndex : 0;\n return Math.max(0, Math.min(idx, Math.max(0, tabs.length - 1)));\n }\n\n function applyView(root, views, tabs, body, index) {\n const view = findCapturedView(views, tabs[index], index);\n if (!view) return;\n body.innerHTML = view.html;\n tabs.forEach((t, j) => styleTab(t, j === index));\n filterItems(root, \"\");\n enhanceMedia(body);\n wireToggles(body);\n wireCardActions(root);\n }\n\n function wireViewTabs(root, views) {\n const tabs = collectionTabs(root);\n const body =\n root.querySelector(\".notion-collection-view-body\") ||\n root.querySelector(\".notion-scroller.notion-collection-view-body\") ||\n root.querySelector(\".notion-scroller.vertical.horizontal\");\n if (!tabs.length) return;\n\n // Always make tabs look/feel clickable (even before captures exist)\n for (const tab of tabs) {\n tab.style.cursor = \"pointer\";\n tab.style.pointerEvents = \"auto\";\n const inner = tab.querySelector('[role=\"tab\"], .notion-collection-view-tab');\n if (inner) {\n inner.style.cursor = \"pointer\";\n inner.style.pointerEvents = \"auto\";\n }\n }\n\n if (!views || !views.tabs || !views.tabs.length || !body) return;\n\n const def = defaultTabIndex(views, tabs);\n // Apply default view on load\n applyView(root, views, tabs, body, def);\n\n tabs.forEach((tab, i) => {\n if (tab.dataset.nspWired) return;\n tab.dataset.nspWired = \"1\";\n tab.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.stopImmediatePropagation) e.stopImmediatePropagation();\n const selected = tab.getAttribute(\"aria-selected\") === \"true\" ||\n tab.querySelector('[aria-selected=\"true\"]');\n // Clicking an already-selected non-default tab returns to Gallery view\n if (selected && i !== def) {\n applyView(root, views, tabs, body, def);\n return;\n }\n applyView(root, views, tabs, body, i);\n }, true);\n });\n }\n\n function wireCollectionsIn(scope, captures) {\n const blocks = $$([\n \".notion-selectable.notion-collection_view-block[data-block-id]\",\n \".notion-collection_view-block[data-block-id]\",\n \".notion-selectable.notion-collection_view_page-block[data-block-id]\",\n \".notion-collection_view_page-block[data-block-id]\",\n ].join(\", \"), scope);\n\n const seen = new Set();\n for (const block of blocks) {\n const root =\n block.closest(\".notion-selectable.notion-collection_view-block\") ||\n block.closest(\".notion-selectable.notion-collection_view_page-block\") ||\n block;\n if (seen.has(root)) continue;\n seen.add(root);\n wireCollectionRoot(root, captures || []);\n }\n }\n\n function wireToggles(scope) {\n for (const block of $$(\".notion-toggle-block\", scope || document)) {\n if (block.dataset.nspToggleWired) continue;\n block.dataset.nspToggleWired = \"1\";\n const btn =\n block.querySelector(\":scope [role='button']\") ||\n block.querySelector(\":scope > div > div[role='button']\");\n const title = block.querySelector(\"[data-content-editable-leaf]\");\n if (!btn && !title) continue;\n\n const contentNodes = () => {\n const marked = $$(\"[data-nsp-toggle-content]\", block);\n if (marked.length) return marked;\n\n // Nested Notion body: .notion-selectable blocks that belong to THIS toggle\n const nested = $$(\".notion-selectable\", block).filter((el) => {\n if (el === block) return false;\n if (el.classList.contains(\"notion-toggle-block\")) return false;\n return el.closest(\".notion-toggle-block\") === block;\n });\n if (nested.length) return nested;\n\n // Sibling of the title row (Script / STUDENT A layout)\n if (title) {\n let row = title.parentElement;\n while (row && row !== block) {\n const parent = row.parentElement;\n if (parent && parent.children.length >= 2) {\n const body = Array.from(parent.children).filter(\n (n) => n !== row && !n.contains(title) && !n.querySelector?.(\"[data-content-editable-leaf]\"),\n );\n if (body.length) return body;\n }\n row = parent;\n }\n }\n\n const rows = Array.from(block.children);\n if (rows.length >= 2) return rows.slice(1);\n const inner = block.querySelector(\":scope > div\");\n if (inner) {\n const kids = Array.from(inner.children);\n if (kids.length >= 2) return kids.slice(1);\n }\n return [];\n };\n\n const setOpen = (open) => {\n block.setAttribute(\"data-nsp-open\", open ? \"1\" : \"0\");\n if (btn) {\n btn.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n btn.setAttribute(\"aria-label\", open ? \"Close\" : \"Open\");\n const svg = btn.querySelector(\"svg\");\n if (svg) {\n svg.style.transform = open ? \"rotateZ(0deg)\" : \"rotateZ(-90deg)\";\n svg.style.fill = \"currentColor\";\n svg.style.color = \"currentColor\";\n }\n }\n for (const n of contentNodes()) {\n if (btn && (n === btn || btn.contains(n))) continue;\n if (title && (n === title || title.contains(n))) continue;\n n.style.display = open ? \"\" : \"none\";\n }\n };\n\n // Always start collapsed offline (content stays in DOM for click-to-open)\n setOpen(false);\n\n const onToggle = (e) => {\n if (e.target.closest(\"a\")) return;\n e.preventDefault();\n e.stopPropagation();\n setOpen(block.getAttribute(\"data-nsp-open\") !== \"1\");\n };\n if (btn) {\n btn.style.cursor = \"pointer\";\n btn.addEventListener(\"click\", onToggle);\n }\n if (title) {\n title.style.cursor = \"pointer\";\n title.addEventListener(\"click\", onToggle);\n }\n }\n }\n\n function wireToc() {\n const toc = $(\".notion-floating-table-of-contents\");\n if (!toc || toc.dataset.nspWired) return;\n toc.dataset.nspWired = \"1\";\n\n const headings = $$(\".notion-page-content h1, .notion-page-content h2, .notion-page-content h3, .notion-page-content .notion-sub_header-block, .notion-page-content .notion-header-block\");\n const entries = [];\n for (const h of headings) {\n const text = (h.textContent || \"\").replace(/\\s+/g, \" \").trim();\n if (!text || text.length > 80) continue;\n if (!h.id) h.id = \"nsp-\" + Math.random().toString(36).slice(2, 9);\n entries.push({ id: h.id, text, el: h });\n }\n if (!entries.length) return;\n\n // Build / enhance popover\n let pop = toc.querySelector(\"[data-nsp-toc-pop]\");\n if (!pop) {\n pop = document.createElement(\"div\");\n pop.setAttribute(\"data-nsp-toc-pop\", \"1\");\n Object.assign(pop.style, {\n display: \"none\",\n position: \"absolute\",\n right: \"28px\",\n top: \"0\",\n minWidth: \"180px\",\n maxWidth: \"240px\",\n padding: \"6px\",\n borderRadius: \"10px\",\n background: \"var(--c-bacPri, #191919)\",\n color: \"var(--c-texPri, #fff)\",\n boxShadow: \"0 8px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(255,255,255,.1)\",\n zIndex: \"1000\",\n fontSize: \"13px\",\n pointerEvents: \"auto\",\n });\n for (const entry of entries) {\n const a = document.createElement(\"a\");\n a.href = \"#\" + entry.id;\n a.textContent = entry.text;\n Object.assign(a.style, {\n display: \"block\",\n padding: \"6px 10px\",\n borderRadius: \"6px\",\n color: \"inherit\",\n textDecoration: \"none\",\n whiteSpace: \"nowrap\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n });\n a.onmouseenter = () => { a.style.background = \"rgba(255,255,255,.08)\"; };\n a.onmouseleave = () => { a.style.background = \"transparent\"; };\n a.addEventListener(\"click\", (ev) => {\n ev.preventDefault();\n entry.el.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n pop.style.display = \"none\";\n });\n pop.appendChild(a);\n }\n const host = toc.querySelector(\"[style*='position: absolute']\") || toc;\n if (getComputedStyle(host).position === \"static\") host.style.position = \"relative\";\n host.appendChild(pop);\n }\n\n const show = () => { pop.style.display = \"block\"; };\n const hide = () => { pop.style.display = \"none\"; };\n toc.addEventListener(\"mouseenter\", show);\n toc.addEventListener(\"mouseleave\", hide);\n toc.style.pointerEvents = \"auto\";\n for (const bar of $$(\"div\", toc)) {\n if (bar === pop || pop.contains(bar)) continue;\n bar.style.cursor = \"pointer\";\n }\n }\n\n function isZoomableImage(img) {\n if (!img || img.tagName !== \"IMG\") return false;\n if (img.closest(\".notion-collection-item a, .notion-gallery-view .notion-collection-item\")) {\n return false;\n }\n if (img.closest(\".notion-bookmark-block\")) return false;\n if (!img.closest(\".notion-image-block, .notion-page-content, [data-nsp-peek-body], .nsp-peek-content\")) {\n return false;\n }\n const src = img.currentSrc || img.getAttribute(\"src\") || \"\";\n if (!src) return false;\n if (src.indexOf(\"data:image/gif\") === 0 || src.indexOf(\"data:image/svg\") === 0) return false;\n return true;\n }\n\n function markZoomable(img) {\n if (!isZoomableImage(img)) {\n img.removeAttribute(\"data-nsp-zoom\");\n return;\n }\n img.setAttribute(\"data-nsp-zoom\", \"1\");\n img.style.cursor = \"zoom-in\";\n img.style.pointerEvents = \"auto\";\n const block = img.closest(\".notion-image-block\") || img.parentElement;\n if (block) {\n block.style.pointerEvents = \"auto\";\n block.style.cursor = \"zoom-in\";\n }\n if (img.dataset.nspZoomWired === \"1\") return;\n img.dataset.nspZoomWired = \"1\";\n img.addEventListener(\"click\", (e) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n e.stopPropagation();\n openLightbox(img);\n });\n }\n\n function enhanceMedia(scope) {\n const root = scope || document;\n repairBlockMedia(root);\n for (const img of $$(\n [\n \".notion-image-block img\",\n \".notion-page-content img\",\n \"[data-nsp-peek-body] img\",\n \".nsp-peek-content img\",\n \"[role='figure'] img\",\n ].join(\", \"),\n root,\n )) {\n // Promote lazy placeholders when a real URL is available\n const lazy =\n img.getAttribute(\"data-src\") ||\n img.getAttribute(\"data-lazy-src\") ||\n img.getAttribute(\"data-original\");\n const src = img.getAttribute(\"src\") || \"\";\n if (lazy && (/^data:image\\/(gif|svg)/i.test(src) || !src)) {\n img.setAttribute(\"src\", lazy);\n }\n // Don't flatten gallery / cover images \u2014 they use fixed heights\n if (!img.closest(\".notion-collection-item, .notion-gallery-view, .notion-bookmark-block\")) {\n img.style.maxWidth = \"100%\";\n img.style.height = \"auto\";\n img.style.maxHeight = \"none\";\n }\n // Bookmark favicons must keep their ~21px size (CSS used to force 100%)\n if (img.closest(\".notion-bookmark-block\")) {\n const st = img.getAttribute(\"style\") || \"\";\n if (/width:s*2[0-9](.d+)?px/i.test(st) || /width:s*1[6-9](.d+)?px/i.test(st)) {\n img.style.setProperty(\"width\", \"22px\", \"important\");\n img.style.setProperty(\"height\", \"22px\", \"important\");\n img.style.setProperty(\"max-width\", \"22px\", \"important\");\n img.style.setProperty(\"max-height\", \"22px\", \"important\");\n }\n }\n repairMediaSrc(img);\n markZoomable(img);\n }\n for (const audio of $$(\"audio\", root)) {\n repairMediaSrc(audio);\n audio.controls = true;\n audio.preload = \"metadata\";\n audio.style.maxWidth = \"100%\";\n audio.style.width = \"100%\";\n audio.style.display = \"block\";\n // Notion often disables hit-testing on the wrapper\n const wrap = audio.closest(\".notion-audio-block, [data-content-editable-void]\");\n if (wrap) {\n wrap.style.pointerEvents = \"auto\";\n }\n audio.style.pointerEvents = \"auto\";\n }\n for (const block of $$(\".notion-audio-block\", root)) {\n block.style.pointerEvents = \"auto\";\n let audio = block.querySelector(\"audio\");\n if (!audio) {\n // Custom Notion player without <audio> \u2014 synthesize from known src attrs\n const srcEl = block.querySelector(\"[src]\");\n const src = srcEl && srcEl.getAttribute(\"src\");\n if (src && !src.startsWith(\"data:\")) {\n audio = document.createElement(\"audio\");\n audio.src = src;\n audio.controls = true;\n audio.preload = \"metadata\";\n audio.style.width = \"100%\";\n audio.style.display = \"block\";\n block.appendChild(audio);\n }\n }\n if (!audio) {\n // Last resort: map by data-block-id \u2192 downloaded file.notion.so asset\n const local = localAssetForBlock(block.getAttribute(\"data-block-id\"), \"audio\");\n if (local) {\n audio = document.createElement(\"audio\");\n audio.src = local;\n audio.controls = true;\n audio.preload = \"metadata\";\n audio.style.width = \"100%\";\n audio.style.maxWidth = \"100%\";\n audio.style.display = \"block\";\n const figure = block.querySelector('[role=\"figure\"]') || block;\n figure.appendChild(audio);\n }\n }\n if (audio) {\n repairMediaSrc(audio);\n audio.controls = true;\n audio.style.width = \"100%\";\n audio.style.maxWidth = \"100%\";\n audio.style.display = \"block\";\n audio.style.pointerEvents = \"auto\";\n }\n }\n for (const table of $$(\".notion-table-view\", root)) {\n const scroller = table.closest(\".notion-scroller\") || table.parentElement;\n if (scroller) {\n scroller.style.overflowX = \"auto\";\n scroller.style.maxWidth = \"100%\";\n }\n table.style.maxWidth = \"none\";\n }\n }\n\n let assetMap = null;\n let assetMapPromise = null;\n let blockAssetIndex = null;\n\n function loadAssetMap() {\n if (assetMapPromise) return assetMapPromise;\n assetMapPromise = (async () => {\n try {\n const res = await fetch(\"./.nsp-cache.json\", { cache: \"force-cache\" });\n if (!res.ok) return;\n const data = await res.json();\n assetMap = data && data.assets ? data.assets : null;\n blockAssetIndex = null;\n } catch {\n assetMap = null;\n blockAssetIndex = null;\n }\n })();\n return assetMapPromise;\n }\n\n function normBlockId(raw) {\n if (!raw) return \"\";\n const hex = String(raw).replace(/-/g, \"\").toLowerCase();\n return /^[0-9a-f]{32}$/.test(hex) ? hex : \"\";\n }\n\n function ensureBlockAssetIndex() {\n if (blockAssetIndex && assetMap) return blockAssetIndex;\n blockAssetIndex = new Map();\n if (!assetMap) return blockAssetIndex;\n for (const [remote, rel] of Object.entries(assetMap)) {\n let blockId = \"\";\n let width = 0;\n try {\n const u = new URL(String(remote).replace(/&/g, \"&\"));\n blockId = normBlockId(u.searchParams.get(\"id\"));\n width = Number(u.searchParams.get(\"width\") || 0) || 0;\n } catch {\n continue;\n }\n if (!blockId) continue;\n const local = \"./\" + String(rel).replace(/^\\.\\//, \"\");\n const entry = blockAssetIndex.get(blockId) || { imageWidth: 0 };\n const isAudio =\n /\\.(mp3|m4a|ogg|wav|aac)(\\?|$)/i.test(remote) ||\n /\\.(mp3|m4a|ogg|wav|aac)$/i.test(local);\n const isImage =\n /\\/image\\//i.test(remote) ||\n /\\.(png|jpe?g|webp|gif|svg)$/i.test(local);\n if (isAudio) entry.audio = local;\n if (isImage && (!entry.image || width >= entry.imageWidth)) {\n entry.image = local;\n entry.imageWidth = width;\n }\n blockAssetIndex.set(blockId, entry);\n }\n return blockAssetIndex;\n }\n\n function localAssetForBlock(blockId, kind) {\n const id = normBlockId(blockId);\n if (!id) return null;\n const idx = ensureBlockAssetIndex();\n const entry = idx.get(id);\n return entry && entry[kind] ? entry[kind] : null;\n }\n\n /** Fix gif placeholders / empty audio+image figures using ?id=<block> assets. */\n function repairBlockMedia(scope) {\n const root = scope || document;\n if (!assetMap) return;\n ensureBlockAssetIndex();\n\n for (const block of $$(\".notion-image-block[data-block-id]\", root)) {\n const local = localAssetForBlock(block.getAttribute(\"data-block-id\"), \"image\");\n if (!local) continue;\n let img = block.querySelector(\"img\");\n if (!img) {\n const figure =\n block.querySelector('[role=\"figure\"]') ||\n block.querySelector(\"[data-content-editable-void]\") ||\n block;\n img = document.createElement(\"img\");\n img.alt = \"\";\n img.referrerPolicy = \"same-origin\";\n img.style.display = \"block\";\n img.style.width = \"100%\";\n img.style.maxWidth = \"100%\";\n img.style.height = \"auto\";\n figure.appendChild(img);\n }\n const src = img.getAttribute(\"src\") || \"\";\n if (!src || /^data:image\\/(gif|svg)/i.test(src) || /^https?:/i.test(src)) {\n img.setAttribute(\"src\", local);\n img.removeAttribute(\"srcset\");\n }\n }\n\n for (const block of $$(\".notion-audio-block[data-block-id]\", root)) {\n if (block.querySelector(\"audio[src]\")) continue;\n const local = localAssetForBlock(block.getAttribute(\"data-block-id\"), \"audio\");\n if (!local) continue;\n const audio = document.createElement(\"audio\");\n audio.controls = true;\n audio.preload = \"metadata\";\n audio.src = local;\n audio.style.width = \"100%\";\n audio.style.maxWidth = \"100%\";\n audio.style.display = \"block\";\n const figure = block.querySelector('[role=\"figure\"]') || block;\n figure.appendChild(audio);\n block.style.pointerEvents = \"auto\";\n }\n }\n\n function localAssetFor(remote) {\n if (!assetMap || !remote) return null;\n if (assetMap[remote]) return \"./\" + assetMap[remote].replace(/^\\.\\//, \"\");\n try {\n const u = new URL(remote);\n const pathKey = u.origin + u.pathname;\n for (const [k, v] of Object.entries(assetMap)) {\n if (k.startsWith(pathKey) || pathKey.startsWith(k.split(\"?\")[0])) {\n return \"./\" + String(v).replace(/^\\.\\//, \"\");\n }\n }\n const uuids = u.pathname.match(\n /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,\n );\n if (uuids) {\n for (const id of uuids) {\n for (const [k, v] of Object.entries(assetMap)) {\n if (k.includes(id)) return \"./\" + String(v).replace(/^\\.\\//, \"\");\n }\n }\n }\n const file = decodeURIComponent(u.pathname.split(\"/\").pop() || \"\");\n if (file) {\n for (const [k, v] of Object.entries(assetMap)) {\n if (k.includes(file)) return \"./\" + String(v).replace(/^\\.\\//, \"\");\n }\n }\n } catch {\n /* ignore */\n }\n return null;\n }\n\n /** If media still points at Notion CDN, remap to a local ./assets file when possible. */\n function repairMediaSrc(el) {\n const src = el.getAttribute(\"src\") || \"\";\n if (!src || src.startsWith(\"./\") || src.startsWith(\"assets/\") || src.startsWith(\"data:\")) {\n return;\n }\n if (!/^https?:/i.test(src)) return;\n const local = localAssetFor(src);\n if (local) {\n el.setAttribute(\"src\", local);\n return;\n }\n // Async: map may still be loading\n loadAssetMap().then(() => {\n const again = localAssetFor(src);\n if (again && el.getAttribute(\"src\") === src) el.setAttribute(\"src\", again);\n });\n }\n\n function collectLightboxImages(fromImg) {\n const scope =\n (fromImg.closest(\"[data-nsp-peek-body]\") ||\n fromImg.closest(\".nsp-peek-content\") ||\n fromImg.closest(\".notion-page-content\") ||\n document);\n const list = $$(\".notion-image-block img\", scope).filter((img) =>\n isZoomableImage(img),\n );\n // Dedupe by src\n const seen = new Set();\n const out = [];\n for (const img of list) {\n const key = img.currentSrc || img.src;\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(img);\n }\n if (isZoomableImage(fromImg) && !out.includes(fromImg)) {\n out.unshift(fromImg);\n }\n return out.length ? out : [fromImg];\n }\n\n function openLightbox(img) {\n // Only remove the overlay dialog \u2014 never [data-nsp-lightbox] on <html>\n // (wireLightbox used to set that flag on documentElement and wiped the page).\n const existing = document.querySelector(\"div[data-nsp-lightbox]\");\n if (existing) existing.remove();\n\n const src0 = (img && (img.currentSrc || img.getAttribute(\"src\") || img.src)) || \"\";\n if (!src0 || src0.indexOf(\"data:image/gif\") === 0 || src0.indexOf(\"data:image/svg\") === 0) {\n return;\n }\n\n const gallery = collectLightboxImages(img);\n let index = gallery.indexOf(img);\n if (index < 0) index = 0;\n\n const overlay = document.createElement(\"div\");\n overlay.setAttribute(\"data-nsp-lightbox\", \"1\");\n overlay.setAttribute(\"role\", \"dialog\");\n overlay.setAttribute(\"aria-modal\", \"true\");\n overlay.style.cssText =\n \"position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483646;\" +\n \"background:#111;display:flex;flex-direction:column;align-items:center;\" +\n \"justify-content:center;padding:16px;box-sizing:border-box;cursor:zoom-out;color:#fff;\";\n\n const toolbar = document.createElement(\"div\");\n toolbar.style.cssText =\n \"position:absolute;top:12px;left:12px;right:12px;display:flex;\" +\n \"align-items:center;justify-content:flex-end;gap:8px;z-index:2;cursor:default;\";\n\n const counter = document.createElement(\"div\");\n counter.style.cssText =\n \"margin-right:auto;color:rgba(255,255,255,.75);font-size:13px;\" +\n \"font-family:ui-sans-serif,system-ui,sans-serif;\";\n\n function mkBtn(label, html, onClick) {\n const b = document.createElement(\"button\");\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = label;\n b.innerHTML = html;\n b.style.cssText =\n \"appearance:none;border:none;background:rgba(255,255,255,.14);color:#fff;\" +\n \"border-radius:8px;padding:8px 10px;cursor:pointer;display:inline-flex;\" +\n \"align-items:center;gap:6px;font-size:13px;\" +\n \"font-family:ui-sans-serif,system-ui,sans-serif;\";\n b.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n onClick(e);\n });\n return b;\n }\n\n const EXPAND_SVG =\n '<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M4.5 3.375A1.125 1.125 0 0 0 3.375 4.5V8a.625.625 0 1 0 1.25 0V5.508l4.067 4.067a.625.625 0 0 0 .884-.884L5.508 4.625H8a.625.625 0 1 0 0-1.25zM15.5 16.625a1.125 1.125 0 0 0 1.125-1.125V12a.625.625 0 1 0-1.25 0v2.492l-4.067-4.067a.625.625 0 1 0-.884.884l4.067 4.066H12a.625.625 0 1 0 0 1.25z\"/></svg>';\n const COMPRESS_SVG =\n '<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M8.125 3.375a.625.625 0 0 1 .625.625V8c0 .69-.56 1.25-1.25 1.25H3.999a.625.625 0 1 1 0-1.25h2.492L2.424 4.183a.625.625 0 1 1 .884-.884L7.375 7.242V5a.625.625 0 0 1 .75-.625zM11.875 16.625a.625.625 0 0 1-.625-.625V12c0-.69.56-1.25 1.25-1.25h3.501a.625.625 0 1 1 0 1.25h-2.492l4.067 4.067a.625.625 0 1 1-.884.884L12.625 12.758V15a.625.625 0 0 1-.75.625z\"/></svg>';\n const EXT_SVG =\n '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M6.75 2a.75.75 0 0 1 0 1.5H3.5v9h9V9.25a.75.75 0 0 1 1.5 0v4A.75.75 0 0 1 13.25 14h-10A.75.75 0 0 1 2.5 13.25v-10A.75.75 0 0 1 3.25 2zm3.72.22a.75.75 0 0 1 .78-.17l.08.04.08.05.06.06.04.05L14.53 5.4a.75.75 0 0 1-1.06 1.06L12.5 5.49V10a.75.75 0 0 1 .75-.75h.01z\"/></svg>';\n\n const panel = document.createElement(\"div\");\n panel.style.cssText =\n \"position:relative;max-width:min(1200px,96vw);max-height:88vh;cursor:default;\" +\n \"display:flex;flex-direction:column;align-items:center;gap:12px;\";\n\n const big = document.createElement(\"img\");\n big.alt = (img && img.alt) || \"\";\n big.style.cssText =\n \"display:block;max-width:96vw;max-height:78vh;width:auto;height:auto;\" +\n \"object-fit:contain;border-radius:6px;background:#1a1a1a;\";\n\n const nav = document.createElement(\"div\");\n nav.style.cssText =\n \"display:flex;gap:8px;align-items:center;justify-content:center;cursor:default;\";\n\n const openFullLink = document.createElement(\"a\");\n openFullLink.target = \"_blank\";\n openFullLink.rel = \"noopener noreferrer\";\n openFullLink.innerHTML = EXT_SVG + \"<span>Open full size</span>\";\n openFullLink.style.cssText =\n \"color:#fff;font-size:13px;text-decoration:none;padding:8px 12px;\" +\n \"border-radius:8px;background:rgba(255,255,255,.14);display:inline-flex;\" +\n \"align-items:center;gap:6px;font-family:ui-sans-serif,system-ui,sans-serif;\";\n openFullLink.addEventListener(\"click\", (e) => e.stopPropagation());\n\n let fsBtn = null;\n\n function syncFsBtn() {\n if (!fsBtn) return;\n const on = !!document.fullscreenElement;\n fsBtn.innerHTML =\n (on ? COMPRESS_SVG : EXPAND_SVG) +\n \"<span>\" + (on ? \"Exit fullscreen\" : \"Fullscreen\") + \"</span>\";\n fsBtn.setAttribute(\"aria-label\", on ? \"Exit fullscreen\" : \"Fullscreen\");\n fsBtn.title = on ? \"Exit fullscreen\" : \"Fullscreen\";\n }\n\n function show(i) {\n if (!gallery.length) {\n big.src = src0;\n openFullLink.href = src0;\n return;\n }\n index = ((i % gallery.length) + gallery.length) % gallery.length;\n const srcImg = gallery[index] || img;\n const src =\n (srcImg && (srcImg.currentSrc || srcImg.getAttribute(\"src\") || srcImg.src)) ||\n src0;\n big.src = src;\n big.alt = (srcImg && srcImg.alt) || \"\";\n openFullLink.href = src;\n counter.textContent =\n gallery.length > 1 ? index + 1 + \" / \" + gallery.length : \"\";\n prevBtn.style.visibility = gallery.length > 1 ? \"visible\" : \"hidden\";\n nextBtn.style.visibility = gallery.length > 1 ? \"visible\" : \"hidden\";\n }\n\n const prevBtn = mkBtn(\"Previous\", \"\u2039\", () => show(index - 1));\n const nextBtn = mkBtn(\"Next\", \"\u203A\", () => show(index + 1));\n prevBtn.style.fontSize = \"22px\";\n nextBtn.style.fontSize = \"22px\";\n prevBtn.style.padding = \"4px 14px\";\n nextBtn.style.padding = \"4px 14px\";\n\n fsBtn = mkBtn(\"Fullscreen\", EXPAND_SVG + \"<span>Fullscreen</span>\", () => {\n const go = async () => {\n try {\n if (document.fullscreenElement) await document.exitFullscreen();\n else await overlay.requestFullscreen();\n } catch (_) {\n const on = overlay.getAttribute(\"data-nsp-fs\") === \"1\";\n overlay.setAttribute(\"data-nsp-fs\", on ? \"0\" : \"1\");\n big.style.maxHeight = on ? \"78vh\" : \"92vh\";\n syncFsBtn();\n }\n };\n go();\n });\n\n function close() {\n const done = async () => {\n try {\n if (document.fullscreenElement) await document.exitFullscreen();\n } catch (_) {}\n overlay.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n document.removeEventListener(\"fullscreenchange\", syncFsBtn);\n };\n done();\n }\n\n function onKey(e) {\n if (!overlay.isConnected) return;\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n close();\n return;\n }\n if (e.key === \"ArrowLeft\") {\n e.preventDefault();\n show(index - 1);\n } else if (e.key === \"ArrowRight\") {\n e.preventDefault();\n show(index + 1);\n }\n }\n\n const closeBtn = mkBtn(\"Close\", CLOSE_SVG, () => close());\n\n toolbar.appendChild(counter);\n toolbar.appendChild(fsBtn);\n toolbar.appendChild(openFullLink);\n toolbar.appendChild(closeBtn);\n nav.appendChild(prevBtn);\n nav.appendChild(nextBtn);\n panel.appendChild(big);\n panel.appendChild(nav);\n overlay.appendChild(toolbar);\n overlay.appendChild(panel);\n\n overlay.addEventListener(\"click\", (e) => {\n if (e.target === overlay) close();\n });\n panel.addEventListener(\"click\", (e) => e.stopPropagation());\n document.addEventListener(\"keydown\", onKey, true);\n document.addEventListener(\"fullscreenchange\", syncFsBtn);\n\n const mount = document.body || document.getElementById(\"notion-app\");\n if (!mount) return;\n mount.appendChild(overlay);\n show(index);\n syncFsBtn();\n }\n\n function padCollectionToolbars() {\n for (const row of $$(\n \".notion-collection_view-block [style*='justify-content: end'], .notion-collection_view_page-block [style*='justify-content: end']\",\n )) {\n if (row.dataset.nspPad) continue;\n row.dataset.nspPad = \"1\";\n row.style.paddingInlineEnd = \"12px\";\n row.style.gap = \"4px\";\n }\n }\n\n function wireCollectionRoot(root, captures) {\n wireCollectionSearch(root);\n wireCollectionFilter(root);\n wireCollectionSort(root);\n const id = root.getAttribute(\"data-block-id\") ||\n root.querySelector(\"[data-block-id]\")?.getAttribute(\"data-block-id\");\n let cap = (captures || []).find((c) => c.blockId === id);\n // Fallback: single capture on the page, or match by nested id\n if (!cap && captures && captures.length === 1) cap = captures[0];\n if (!cap && id && captures) {\n cap = captures.find((c) =>\n root.querySelector('[data-block-id=\"' + c.blockId + '\"]'),\n );\n }\n wireViewTabs(root, cap);\n }\n\n function wireLightbox() {\n if (document.documentElement.dataset.nspLbWired) return;\n document.documentElement.dataset.nspLbWired = \"1\";\n document.addEventListener(\"click\", (e) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n const t = e.target;\n if (!t || !t.closest) return;\n if (t.closest(\"div[data-nsp-lightbox]\")) return;\n let img = t.closest(\"img[data-nsp-zoom], .notion-image-block img\");\n if (!img) {\n const host = t.closest(\".notion-image-block\");\n if (host) img = host.querySelector(\"img\");\n }\n if (!img || !isZoomableImage(img)) return;\n e.preventDefault();\n e.stopPropagation();\n if (e.stopImmediatePropagation) e.stopImmediatePropagation();\n openLightbox(img);\n }, true);\n }\n\n function injectStyles() {\n if (document.getElementById(\"nsp-runtime-css\")) return;\n const style = document.createElement(\"style\");\n style.id = \"nsp-runtime-css\";\n style.textContent = `\n .notion-image-block, .notion-image-block > div { max-width: 100% !important; }\n .notion-image-block,\n .notion-image-block * {\n pointer-events: auto !important;\n }\n .notion-image-block img,\n .notion-page-content .notion-image-block img,\n [data-nsp-peek-body] .notion-image-block img,\n .nsp-peek-content .notion-image-block img,\n img[data-nsp-zoom=\"1\"] {\n max-width: 100% !important;\n cursor: zoom-in !important;\n pointer-events: auto !important;\n }\n .notion-image-block,\n .notion-image-block [role=\"figure\"],\n .notion-image-block [data-content-editable-void] {\n cursor: zoom-in !important;\n }\n .notion-image-block img,\n [data-nsp-peek-body] .notion-image-block img,\n .nsp-peek-content .notion-image-block img {\n width: 100% !important;\n height: auto !important;\n max-height: none !important;\n object-fit: contain !important;\n }\n [data-nsp-peek-body] .notion-page-content,\n .nsp-peek-content .notion-page-content,\n [data-nsp-peek-body] .notion-image-block [role=\"figure\"],\n .nsp-peek-content .notion-image-block [role=\"figure\"] {\n max-width: 100% !important;\n overflow-x: hidden;\n }\n .notion-collection-item img,\n .notion-gallery-view img {\n /* keep Notion cover sizing \u2014 don't force height:auto */\n max-width: 100%;\n cursor: pointer;\n }\n .notion-bookmark-block {\n cursor: pointer !important;\n max-width: 100% !important;\n }\n .notion-bookmark-block a {\n max-width: 100% !important;\n width: 100% !important;\n display: flex !important;\n flex-wrap: wrap !important;\n align-items: stretch !important;\n overflow: hidden !important;\n box-sizing: border-box !important;\n }\n .notion-bookmark-block a > div {\n min-width: 0 !important;\n box-sizing: border-box !important;\n }\n /* Cover images only \u2014 do NOT force favicons to 100% width */\n .notion-bookmark-block img[style*=\"width: 100%\"],\n .notion-bookmark-block img[style*=\"height: 100%\"] {\n max-width: 280px !important;\n max-height: 154px !important;\n width: 100% !important;\n height: 100% !important;\n object-fit: cover !important;\n }\n /* Favicon / small icon row (Notion uses ~21px) */\n .notion-bookmark-block img[style*=\"21.312px\"],\n .notion-bookmark-block img[style*=\"width: 16px\"],\n .notion-bookmark-block img[style*=\"width: 18px\"],\n .notion-bookmark-block img[style*=\"width: 20px\"],\n .notion-bookmark-block img[style*=\"width: 21px\"],\n .notion-bookmark-block img[style*=\"width: 22px\"],\n .notion-bookmark-block img[style*=\"width: 24px\"] {\n width: 22px !important;\n height: 22px !important;\n max-width: 22px !important;\n max-height: 22px !important;\n flex-shrink: 0 !important;\n object-fit: cover !important;\n }\n .notion-audio-block, .notion-audio-block audio {\n max-width: 100% !important;\n width: 100% !important;\n pointer-events: auto !important;\n box-sizing: border-box !important;\n }\n .notion-audio-block {\n align-self: stretch !important;\n display: block !important;\n }\n .notion-audio-block audio {\n display: block !important;\n min-height: 40px;\n margin-top: 4px;\n }\n .notion-audio-block [role=\"figure\"] {\n min-height: 40px;\n width: 100% !important;\n pointer-events: auto !important;\n }\n .notion-table-view, .notion-scroller.horizontal {\n overflow-x: auto !important;\n max-width: 100%;\n }\n .notion-collection-view-body,\n .notion-gallery-view,\n .notion-calendar-view,\n .notion-table-view {\n background: var(--c-bacPri, inherit);\n min-height: 40vh;\n }\n .notion-collection-view-tab[data-nsp-active=\"1\"],\n .notion-collection-view-tab[aria-selected=\"true\"],\n .notion-collection-view-tab-button[data-nsp-active=\"1\"],\n .notion-collection-view-tab-button [aria-selected=\"true\"] {\n background: var(--ca-graBacSecTra, rgba(255,255,255,.1)) !important;\n }\n /* Notion print CSS often hides chrome \u2014 keep breadcrumbs visible */\n .notion-topbar {\n display: flex !important;\n visibility: visible !important;\n opacity: 1 !important;\n height: 44px !important;\n z-index: 10;\n position: relative;\n }\n header {\n display: block !important;\n visibility: visible !important;\n position: relative;\n z-index: 10;\n }\n [data-nsp-peek-body] .notion-topbar,\n [data-nsp-peek-body] header {\n position: sticky;\n top: 0;\n z-index: 5;\n background: var(--c-bacPri, #191919);\n }\n .notion-collection-view-tab-button,\n .notion-collection-view-tab,\n [role=\"tab\"].notion-collection-view-tab {\n cursor: pointer !important;\n pointer-events: auto !important;\n }\n .notion-toggle-block [role=\"button\"],\n .notion-toggle-block [data-content-editable-leaf],\n .notion-toggle-block { cursor: pointer; }\n .notion-dark-theme .notion-toggle-block [role=\"button\"],\n .notion-dark-theme .notion-toggle-block [role=\"button\"] svg {\n color: #fff !important;\n fill: #fff !important;\n }\n .notion-light-theme .notion-toggle-block [role=\"button\"],\n .notion-light-theme .notion-toggle-block [role=\"button\"] svg {\n color: #37352f !important;\n fill: #37352f !important;\n }\n .notion-toggle-block [data-nsp-toggle-content] {\n margin-top: 2px;\n }\n .notion-floating-table-of-contents { pointer-events: auto !important; }\n\n /* Column lists: never overflow the frame; stack when narrow / in peek */\n .notion-column_list-block > div {\n flex-wrap: wrap !important;\n max-width: 100% !important;\n width: 100% !important;\n box-sizing: border-box !important;\n }\n .notion-column_list-block > div > div {\n max-width: 100% !important;\n box-sizing: border-box !important;\n }\n .notion-column_list-block > div > div[style*=\"opacity: 0\"],\n .notion-column_list-block > div > div[style*=\"width: 46px\"] {\n display: none !important;\n height: 0 !important;\n width: 0 !important;\n padding: 0 !important;\n margin: 0 !important;\n flex: 0 0 0 !important;\n }\n [data-nsp-peek-body] .notion-column_list-block > div,\n .nsp-peek-content .notion-column_list-block > div {\n flex-direction: column !important;\n align-items: stretch !important;\n }\n [data-nsp-peek-body] .notion-column_list-block > div > div,\n .nsp-peek-content .notion-column_list-block > div > div {\n width: 100% !important;\n max-width: 100% !important;\n flex: 1 1 auto !important;\n }\n\n /* Mobile: stack Notion column layouts */\n @media (max-width: 900px) {\n .notion-column_list-block > div {\n flex-direction: column !important;\n align-items: stretch !important;\n }\n .notion-column_list-block > div > div {\n width: 100% !important;\n max-width: 100% !important;\n flex-grow: 1 !important;\n flex-shrink: 1 !important;\n }\n .layout, .layout-wide, .layout-content {\n padding-inline: 12px !important;\n }\n .notion-page-content {\n padding-inline: 4px !important;\n }\n }\n\n /* Side / center peek */\n [data-nsp-peek-root] {\n position: fixed;\n inset: 0;\n z-index: 30000;\n pointer-events: none;\n visibility: hidden;\n }\n [data-nsp-peek-root][data-open=\"1\"] {\n visibility: visible;\n pointer-events: auto;\n }\n [data-nsp-peek-backdrop] {\n position: absolute;\n inset: 0;\n background: rgba(0,0,0,.45);\n pointer-events: none;\n opacity: 0;\n transition: opacity .18s ease;\n }\n [data-nsp-peek-root][data-open=\"1\"] [data-nsp-peek-backdrop] {\n opacity: 1;\n pointer-events: auto;\n }\n [data-nsp-peek-panel] {\n position: absolute;\n top: 0;\n right: 0;\n height: 100%;\n width: min(640px, 100%);\n background: var(--c-bacPri, #191919);\n color: var(--c-texPri, #fff);\n box-shadow: -8px 0 32px rgba(0,0,0,.35);\n display: flex;\n flex-direction: column;\n pointer-events: none;\n transform: translateX(104%);\n transition: transform .2s ease, width .2s ease;\n }\n [data-nsp-peek-root][data-open=\"1\"] [data-nsp-peek-panel] {\n transform: translateX(0);\n pointer-events: auto;\n }\n [data-nsp-peek-root][data-mode=\"center\"] [data-nsp-peek-panel] {\n left: 50%;\n right: auto;\n top: 4vh;\n height: 92vh;\n width: min(820px, 94vw);\n transform: translate(-50%, 12px);\n opacity: 0;\n border-radius: 12px;\n box-shadow: 0 16px 48px rgba(0,0,0,.45);\n }\n [data-nsp-peek-root][data-open=\"1\"][data-mode=\"center\"] [data-nsp-peek-panel] {\n transform: translate(-50%, 0);\n opacity: 1;\n }\n [data-nsp-peek-root][data-mode=\"full\"] [data-nsp-peek-backdrop] {\n opacity: 0 !important;\n pointer-events: none !important;\n }\n [data-nsp-peek-root][data-mode=\"full\"] [data-nsp-peek-panel] {\n left: 0;\n right: 0;\n top: 0;\n width: 100%;\n height: 100%;\n max-width: none;\n border-radius: 0;\n box-shadow: none;\n transform: none;\n opacity: 1;\n }\n [data-nsp-peek-root][data-open=\"1\"][data-mode=\"full\"] [data-nsp-peek-panel] {\n transform: none;\n opacity: 1;\n }\n [data-nsp-peek-bar] {\n height: 44px;\n flex-shrink: 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 10px 0 12px;\n border-bottom: 1px solid rgba(255,255,255,.08);\n opacity: 0;\n transition: opacity .15s ease;\n }\n [data-nsp-peek-panel]:hover [data-nsp-peek-bar],\n [data-nsp-peek-bar]:focus-within,\n [data-nsp-peek-root][data-peek-bar=\"1\"] [data-nsp-peek-bar] {\n opacity: 1;\n }\n [data-nsp-peek-bar] button, [data-nsp-peek-bar] a {\n appearance: none;\n border: none;\n background: transparent;\n color: inherit;\n cursor: pointer;\n padding: 6px;\n border-radius: 6px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n text-decoration: none;\n opacity: .8;\n }\n [data-nsp-peek-bar] button:hover, [data-nsp-peek-bar] a:hover {\n background: rgba(255,255,255,.08);\n opacity: 1;\n }\n [data-nsp-peek-body] {\n flex: 1;\n overflow: auto;\n padding: 16px 20px 48px;\n }\n [data-nsp-peek-body] .layout,\n [data-nsp-peek-body] .layout-wide {\n padding-inline: 8px !important;\n max-width: 100% !important;\n }\n [data-nsp-card-actions] {\n position: absolute;\n top: 8px;\n right: 8px;\n display: none;\n gap: 4px;\n z-index: 5;\n background: var(--c-whiButBac, #fff);\n color: var(--c-texSec, #333);\n border-radius: 6px;\n box-shadow: var(--c-shaOutMd, 0 2px 8px rgba(0,0,0,.2));\n padding: 2px;\n }\n .notion-collection-item { position: relative; }\n .notion-collection-item:hover [data-nsp-card-actions],\n .notion-collection-item:focus-within [data-nsp-card-actions] {\n display: flex;\n }\n [data-nsp-card-actions] button {\n appearance: none;\n border: none;\n background: transparent;\n cursor: pointer;\n padding: 4px 6px;\n border-radius: 4px;\n font-size: 12px;\n color: inherit;\n }\n [data-nsp-card-actions] button:hover { background: rgba(0,0,0,.06); }\n [data-nsp-topbar-right] {\n display: flex !important;\n align-items: center;\n margin-inline-start: auto !important;\n flex-shrink: 0;\n }\n [data-nsp-install] { display: none !important; }\n `;\n document.head.appendChild(style);\n }\n\n /* \u2500\u2500 PWA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n function registerPwa() {\n if (!(\"serviceWorker\" in navigator)) return;\n const swUrl = new URL(\"sw.js\", location.href);\n // All pages live flat in the site root\n const rootSw = new URL(\"./sw.js\", location.href.replace(/[^/]*$/, \"\"));\n navigator.serviceWorker.register(rootSw.href).catch(() => {\n navigator.serviceWorker.register(swUrl.href).catch(() => {});\n });\n\n // Ask SW to refresh precache list after load\n navigator.serviceWorker.ready.then(async (reg) => {\n try {\n const res = await fetch(\"./assets/nsp-precache.json\", { cache: \"no-cache\" });\n const urls = await res.json();\n reg.active && reg.active.postMessage({ type: \"NSP_CACHE_URLS\", urls });\n } catch (_) {}\n });\n }\n\n /* \u2500\u2500 Side peek \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n let peekMode = localStorage.getItem(\"nsp-peek-mode\") || \"side\";\n let peekRoot = null;\n let peekCurrentHref = \"\";\n let peekFull = false;\n let peekHistoryPushed = false;\n let peekIgnorePop = false;\n let peekLoadGen = 0;\n\n function ensurePeekRoot() {\n if (peekRoot && peekRoot.isConnected) return peekRoot;\n peekRoot = document.createElement(\"div\");\n peekRoot.setAttribute(\"data-nsp-peek-root\", \"1\");\n peekRoot.setAttribute(\"data-open\", \"0\");\n peekRoot.setAttribute(\"data-mode\", peekMode);\n // Inherit dark/light CSS variables from Notion theme host\n const theme =\n document.querySelector(\".notion-dark-theme\") ? \"notion-dark-theme\" :\n document.querySelector(\".notion-light-theme\") ? \"notion-light-theme\" : \"\";\n if (theme) peekRoot.classList.add(theme);\n peekRoot.innerHTML =\n '<div data-nsp-peek-backdrop></div>' +\n '<div data-nsp-peek-panel role=\"dialog\" aria-modal=\"true\">' +\n ' <div data-nsp-peek-bar>' +\n ' <div data-nsp-peek-left style=\"display:flex;gap:2px;align-items:center\"></div>' +\n ' <div data-nsp-peek-right style=\"display:flex;gap:2px;align-items:center\"></div>' +\n \" </div>\" +\n ' <div data-nsp-peek-body></div>' +\n \"</div>\";\n themeHost().appendChild(peekRoot);\n peekRoot.querySelector(\"[data-nsp-peek-backdrop]\").addEventListener(\"click\", closePeek);\n return peekRoot;\n }\n\n function iconBtn(label, svg, onClick) {\n const b = document.createElement(\"button\");\n b.type = \"button\";\n b.setAttribute(\"aria-label\", label);\n b.title = label;\n b.innerHTML = svg;\n b.addEventListener(\"click\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n onClick();\n });\n return b;\n }\n\n const SVG = {\n close: CLOSE_SVG,\n expand: '<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M4.5 3.375A1.125 1.125 0 0 0 3.375 4.5V8a.625.625 0 1 0 1.25 0V5.508l4.067 4.067a.625.625 0 0 0 .884-.884L5.508 4.625H8a.625.625 0 1 0 0-1.25zM15.5 16.625a1.125 1.125 0 0 0 1.125-1.125V12a.625.625 0 1 0-1.25 0v2.492l-4.067-4.067a.625.625 0 1 0-.884.884l4.067 4.066H12a.625.625 0 1 0 0 1.25z\"/></svg>',\n compress: '<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M8.125 3.375a.625.625 0 0 1 .625.625V8c0 .69-.56 1.25-1.25 1.25H3.999a.625.625 0 1 1 0-1.25h2.492L2.424 4.183a.625.625 0 1 1 .884-.884L7.375 7.242V5a.625.625 0 0 1 .75-.625zM11.875 16.625a.625.625 0 0 1-.625-.625V12c0-.69.56-1.25 1.25-1.25h3.501a.625.625 0 1 1 0 1.25h-2.492l4.067 4.067a.625.625 0 1 1-.884.884L12.625 12.758V15a.625.625 0 0 1-.75.625z\"/></svg>',\n newtab: '<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M6.25 3.375A2.125 2.125 0 0 0 4.125 5.5v8.75c0 1.174.951 2.125 2.125 2.125h8.75a2.125 2.125 0 0 0 2.125-2.125v-3.125a.625.625 0 1 0-1.25 0V14.25a.875.875 0 0 1-.875.875H6.25a.875.875 0 0 1-.875-.875V5.5c0-.483.392-.875.875-.875h3.125a.625.625 0 1 0 0-1.25z\"/><path d=\"M12.625 3.375a.625.625 0 0 0 0 1.25h2.117l-5.246 5.246a.625.625 0 1 0 .884.884l5.245-5.246v2.116a.625.625 0 1 0 1.25 0V4.5A1.125 1.125 0 0 0 15.75 3.375z\"/></svg>',\n side: '<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M3.375 5.5c0-1.174.951-2.125 2.125-2.125h9c1.174 0 2.125.951 2.125 2.125v9c0 1.174-.951 2.125-2.125 2.125h-9A2.125 2.125 0 0 1 3.375 14.5zm10.875-.875h-2.5v10.75h2.5a.875.875 0 0 0 .875-.875v-9a.875.875 0 0 0-.875-.875z\"/></svg>',\n center: '<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M4.5 3.375A2.125 2.125 0 0 0 2.375 5.5v9c0 1.174.951 2.125 2.125 2.125h11A2.125 2.125 0 0 0 17.625 14.5v-9A2.125 2.125 0 0 0 15.5 3.375zm0 1.25h11c.483 0 .875.392.875.875v9a.875.875 0 0 1-.875.875h-11a.875.875 0 0 1-.875-.875v-9c0-.483.392-.875.875-.875z\"/></svg>',\n };\n\n function displayPeekMode() {\n return peekFull ? \"full\" : peekMode;\n }\n\n function closePeek() {\n if (!peekRoot) return;\n // Drop fullscreen history entry without re-opening peek\n if (peekFull && peekHistoryPushed) {\n peekIgnorePop = true;\n peekHistoryPushed = false;\n peekFull = false;\n history.back();\n } else {\n peekFull = false;\n peekHistoryPushed = false;\n }\n peekRoot.setAttribute(\"data-open\", \"0\");\n peekRoot.setAttribute(\"data-mode\", peekMode);\n peekRoot.querySelector(\"[data-nsp-peek-body]\").innerHTML = \"\";\n peekCurrentHref = \"\";\n document.body.style.overflow = \"\";\n }\n\n function exitFullPeek() {\n if (!peekFull) return;\n if (peekHistoryPushed) {\n peekHistoryPushed = false;\n history.back(); // popstate \u2192 restorePeekAfterFull\n return;\n }\n peekFull = false;\n if (peekCurrentHref) openPeek(peekCurrentHref, peekMode);\n else closePeek();\n }\n\n function enterFullPeek(href) {\n if (peekFull) return;\n peekFull = true;\n try {\n history.pushState({ nspPeekFull: true, href }, \"\", href);\n peekHistoryPushed = true;\n } catch (_) {\n peekHistoryPushed = false;\n }\n openPeek(href);\n }\n\n function restorePeekAfterFull() {\n peekFull = false;\n peekHistoryPushed = false;\n if (peekCurrentHref) openPeek(peekCurrentHref, peekMode);\n else closePeek();\n }\n\n async function openPeek(href, mode) {\n if (mode === \"full\") {\n enterFullPeek(href);\n return;\n }\n if (mode === \"side\" || mode === \"center\") {\n peekMode = mode;\n localStorage.setItem(\"nsp-peek-mode\", peekMode);\n peekFull = false;\n }\n const root = ensurePeekRoot();\n root.setAttribute(\"data-mode\", displayPeekMode());\n peekCurrentHref = href;\n document.body.style.overflow = \"hidden\";\n\n const left = root.querySelector(\"[data-nsp-peek-left]\");\n const right = root.querySelector(\"[data-nsp-peek-right]\");\n const body = root.querySelector(\"[data-nsp-peek-body]\");\n left.innerHTML = \"\";\n right.innerHTML = \"\";\n\n if (peekFull) {\n left.appendChild(iconBtn(\"Exit full page\", SVG.compress, exitFullPeek));\n } else {\n left.appendChild(iconBtn(\"Open in full page\", SVG.expand, () => {\n enterFullPeek(href);\n }));\n }\n left.appendChild(iconBtn(\"Open in new tab\", SVG.newtab, () => {\n window.open(href, \"_blank\", \"noopener,noreferrer\");\n }));\n if (!peekFull) {\n left.appendChild(iconBtn(\n peekMode === \"side\" ? \"Center peek\" : \"Side peek\",\n peekMode === \"side\" ? SVG.center : SVG.side,\n () => openPeek(href, peekMode === \"side\" ? \"center\" : \"side\"),\n ));\n }\n right.appendChild(iconBtn(\"Close\", SVG.close, closePeek));\n\n const gen = ++peekLoadGen;\n body.innerHTML = '<div style=\"padding:24px;opacity:.6\">Loading\u2026</div>';\n root.setAttribute(\"data-open\", \"1\");\n root.setAttribute(\"data-peek-bar\", \"1\");\n setTimeout(() => {\n if (gen === peekLoadGen) root.setAttribute(\"data-peek-bar\", \"0\");\n }, 1800);\n\n try {\n const res = await fetch(href);\n if (gen !== peekLoadGen) return;\n if (!res.ok) throw new Error(\"HTTP \" + res.status);\n const html = await res.text();\n if (gen !== peekLoadGen) return;\n const doc = new DOMParser().parseFromString(html, \"text/html\");\n let captures = [];\n try {\n const raw = doc.getElementById(\"nsp-collection-views\");\n if (raw) captures = JSON.parse(raw.textContent || \"[]\");\n } catch (_) {}\n\n const header =\n doc.querySelector(\"header\") ||\n doc.querySelector(\".notion-topbar\")?.closest(\"header\") ||\n doc.querySelector(\".notion-topbar\");\n const main =\n doc.querySelector(\"main#main\") ||\n doc.querySelector(\".notion-page-content\") ||\n doc.body;\n\n if (gen !== peekLoadGen) return;\n body.innerHTML = \"\";\n const wrap = document.createElement(\"div\");\n wrap.className = \"nsp-peek-content\";\n if (header) {\n wrap.appendChild(header.cloneNode(true));\n }\n if (main && main !== header) {\n const mWrap = document.createElement(\"div\");\n mWrap.setAttribute(\"data-nsp-peek-main\", \"1\");\n mWrap.innerHTML = main.innerHTML;\n wrap.appendChild(mWrap);\n } else if (!header && main) {\n wrap.innerHTML = main.innerHTML;\n }\n body.appendChild(wrap);\n // Re-wire interactive bits inside peek\n wireToggles(body);\n enhanceMedia(body);\n loadAssetMap().then(() => {\n if (gen === peekLoadGen) enhanceMedia(body);\n });\n wireCardActions(body);\n wireCollectionsIn(body, captures);\n padCollectionToolbars();\n layoutTopbar();\n // Internal links inside peek open in peek (keep fullscreen if active)\n body.onclick = (e) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n const a = e.target.closest(\"a[href]\");\n if (!a || a.closest(\"[data-nsp-card-actions]\")) return;\n // Let collection view tabs handle their own clicks\n if (a.closest(\".notion-collection-view-tab-button\")) return;\n const h = a.getAttribute(\"href\") || \"\";\n if (!isLocalHtmlHref(h)) return;\n e.preventDefault();\n e.stopPropagation();\n const next = h.split(\"#\")[0];\n if (peekFull) {\n peekCurrentHref = next;\n try {\n history.replaceState({ nspPeekFull: true, href: next }, \"\", next);\n } catch (_) {}\n openPeek(next);\n } else {\n openPeek(next, peekMode);\n }\n };\n } catch (err) {\n if (gen !== peekLoadGen) return;\n body.innerHTML =\n '<div style=\"padding:24px\">Could not load page. <a href=\"' +\n href +\n '\">Open full page</a></div>';\n }\n }\n\n if (!window.__nspPeekPopstate) {\n window.__nspPeekPopstate = true;\n window.addEventListener(\"popstate\", () => {\n if (peekIgnorePop) {\n peekIgnorePop = false;\n return;\n }\n if (peekFull) restorePeekAfterFull();\n });\n }\n\n function isLocalHtmlHref(href) {\n if (!href || href.startsWith(\"#\") || href.startsWith(\"mailto:\")) return false;\n if (href.startsWith(\"http\")) {\n try {\n const u = new URL(href);\n return u.origin === location.origin && /\\.html($|\\?)/.test(u.pathname);\n } catch {\n return false;\n }\n }\n return /\\.html($|\\?|#)/.test(href);\n }\n\n function wirePeekNavigation() {\n if (document.documentElement.dataset.nspPeekNav) return;\n document.documentElement.dataset.nspPeekNav = \"1\";\n document.addEventListener(\"click\", (e) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n const a = e.target.closest(\"a[href]\");\n if (!a) return;\n if (a.closest(\"[data-nsp-peek-bar], [data-nsp-menu], [data-nsp-card-actions]\")) return;\n const href = a.getAttribute(\"href\") || \"\";\n if (!isLocalHtmlHref(href)) return;\n const isCollection = a.closest(\n \".notion-collection-item, .notion-gallery-view, .notion-table-view, .notion-board-view, .notion-calendar-view, .notion-list-view, .notion-collection_view-block, .notion-collection_view_page-block\",\n );\n if (!isCollection) return;\n e.preventDefault();\n e.stopPropagation();\n openPeek(href.split(\"#\")[0] || href);\n }, true);\n }\n\n function wireCardActions(scope) {\n const root = scope || document;\n for (const item of $$(\".notion-collection-item\", root)) {\n if (item.dataset.nspCard) continue;\n item.dataset.nspCard = \"1\";\n const a = item.querySelector(\"a[href]\");\n if (!a) continue;\n const href = a.getAttribute(\"href\") || \"\";\n if (!isLocalHtmlHref(href)) continue;\n const actions = document.createElement(\"div\");\n actions.setAttribute(\"data-nsp-card-actions\", \"1\");\n const mk = (label, fn) => {\n const b = document.createElement(\"button\");\n b.type = \"button\";\n b.textContent = label;\n b.addEventListener(\"click\", (ev) => {\n ev.preventDefault();\n ev.stopPropagation();\n fn();\n });\n return b;\n };\n actions.appendChild(mk(\"Peek\", () => openPeek(href.split(\"#\")[0])));\n actions.appendChild(mk(\"New tab\", () => window.open(href, \"_blank\", \"noopener,noreferrer\")));\n actions.appendChild(mk(\"Open\", () => { location.href = href; }));\n const host = item.querySelector(\"[role='presentation']\") || item;\n if (getComputedStyle(host).position === \"static\") host.style.position = \"relative\";\n host.appendChild(actions);\n }\n }\n\n function boot() {\n injectStyles();\n registerPwa();\n loadAssetMap();\n syncViewport();\n hidePromos();\n wireOriginalMenu();\n layoutTopbar();\n wireToggles(document);\n wireToc();\n wireLightbox();\n enhanceMedia(document);\n // Re-run media repair after cache map loads\n loadAssetMap().then(() => enhanceMedia(document));\n padCollectionToolbars();\n wirePeekNavigation();\n wireCardActions(document);\n window.addEventListener(\"resize\", syncViewport);\n document.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\") {\n if (document.querySelector(\"div[data-nsp-lightbox]\")) return;\n closePeek();\n }\n });\n\n let captures = [];\n try {\n const raw = document.getElementById(\"nsp-collection-views\");\n if (raw) captures = JSON.parse(raw.textContent || \"[]\");\n } catch (_) {}\n\n wireCollectionsIn(document, captures);\n }\n\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", boot);\n } else {\n boot();\n }\n})();\n";
|
|
6
|
+
export declare function injectRuntime(html: string, collectionViews: unknown[], runtimeSrc: string, originalUrl?: string): string;
|
|
7
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,UAAU,s7/EAgnEtB,CAAC;AAEF,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,OAAO,EAAE,EAC1B,UAAU,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,MAAM,GACnB,MAAM,CAoHR"}
|
package/dist/scrape.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { backupDir, restoreBackup, stagingDir } from "./cache.ts";
|
|
2
|
+
export type SyncOptions = {
|
|
3
|
+
url: string;
|
|
4
|
+
out: string;
|
|
5
|
+
keepCname?: boolean;
|
|
6
|
+
maxPages?: number;
|
|
7
|
+
headless?: boolean;
|
|
8
|
+
userDataDir?: string;
|
|
9
|
+
delayMs?: number;
|
|
10
|
+
maxRetries?: number;
|
|
11
|
+
/** Parallel browser pages. Default 4 */
|
|
12
|
+
concurrency?: number;
|
|
13
|
+
/** Ignore fingerprints and re-scrape every page */
|
|
14
|
+
full?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type SyncResult = {
|
|
17
|
+
pages: number;
|
|
18
|
+
skipped: number;
|
|
19
|
+
assets: number;
|
|
20
|
+
out: string;
|
|
21
|
+
backup: string | null;
|
|
22
|
+
/** Wall-clock sync duration in milliseconds */
|
|
23
|
+
durationMs: number;
|
|
24
|
+
};
|
|
25
|
+
export { restoreBackup, backupDir, stagingDir };
|
|
26
|
+
export declare function syncNotionSite(opts: SyncOptions): Promise<SyncResult>;
|
|
27
|
+
export declare function formatDuration(ms: number): string;
|
|
28
|
+
export declare function preserveCname(outRoot: string, from?: string): void;
|
|
29
|
+
export declare function printSyncFailure(err: unknown): never;
|
|
30
|
+
//# sourceMappingURL=scrape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scrape.d.ts","sourceRoot":"","sources":["../src/scrape.ts"],"names":[],"mappings":"AA+DA,OAAO,EACL,SAAS,EAST,aAAa,EAGb,UAAU,EAEX,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wCAAwC;IACxC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC;AAEhD,wBAAsB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAsR3E;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAOjD;AA+SD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAKlE;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,KAAK,CAGpD"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Page } from "puppeteer";
|
|
2
|
+
export type CollectionViewCapture = {
|
|
3
|
+
blockId: string;
|
|
4
|
+
defaultLabel?: string;
|
|
5
|
+
defaultIndex?: number;
|
|
6
|
+
tabs: {
|
|
7
|
+
label: string;
|
|
8
|
+
html: string;
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Click each collection view tab and capture the rendered body HTML so we can
|
|
13
|
+
* switch views offline without Notion's React runtime.
|
|
14
|
+
*/
|
|
15
|
+
export declare function captureCollectionViews(page: Page): Promise<CollectionViewCapture[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Notion lazy-loads toggle children only after a real UI expand.
|
|
18
|
+
* Synthetic evaluate-clicks often leave aria-expanded=false with empty bodies
|
|
19
|
+
* (e.g. STUDENT A / STUDENT B). Use CDP clicks and wait for content.
|
|
20
|
+
*/
|
|
21
|
+
export declare function expandAllToggles(page: Page): Promise<number>;
|
|
22
|
+
/**
|
|
23
|
+
* Force Notion to mount lazy image/audio into the DOM, and extract source URLs
|
|
24
|
+
* from React fiber props when the custom player stays empty.
|
|
25
|
+
* Returns discovered remote media URLs (for download).
|
|
26
|
+
*/
|
|
27
|
+
export declare function hydrateNotionMedia(page: Page): Promise<string[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Prepare the painted Notion DOM for a static host:
|
|
30
|
+
* keep header chrome, strip promo CTAs, fix fixed desktop widths,
|
|
31
|
+
* leave scripts out (we inject our own offline runtime instead).
|
|
32
|
+
*/
|
|
33
|
+
export declare function freezeNotionPage(page: Page): Promise<string>;
|
|
34
|
+
/** Wait until the live Notion UI shell + page content are painted. */
|
|
35
|
+
export declare function waitForLiveNotionUi(page: Page): Promise<void>;
|
|
36
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot.d.ts","sourceRoot":"","sources":["../src/snapshot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEtC,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACzC,CAAC;AAEF;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,IAAI,GACT,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAgQlC;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CA8DlE;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAoStE;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAoPlE;AAED,sEAAsE;AACtE,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBnE"}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const c: {
|
|
2
|
+
dim: (s: string) => string;
|
|
3
|
+
green: (s: string) => string;
|
|
4
|
+
yellow: (s: string) => string;
|
|
5
|
+
red: (s: string) => string;
|
|
6
|
+
cyan: (s: string) => string;
|
|
7
|
+
bold: (s: string) => string;
|
|
8
|
+
};
|
|
9
|
+
export declare function header(title: string): void;
|
|
10
|
+
export declare function info(msg: string): void;
|
|
11
|
+
export declare function success(msg: string): void;
|
|
12
|
+
export declare function warn(msg: string): void;
|
|
13
|
+
export declare function fail(msg: string): void;
|
|
14
|
+
export declare function note(msg: string): void;
|
|
15
|
+
export declare function blank(): void;
|
|
16
|
+
export declare function kv(key: string, value: string): void;
|
|
17
|
+
type ProgressState = {
|
|
18
|
+
done: number;
|
|
19
|
+
queued: number;
|
|
20
|
+
active: number;
|
|
21
|
+
skipped: number;
|
|
22
|
+
known: number;
|
|
23
|
+
};
|
|
24
|
+
export declare function setProgress(partial: Partial<ProgressState>): void;
|
|
25
|
+
export declare function setPhase(phase: string, label?: string): void;
|
|
26
|
+
export declare function startSpinner(label: string, phase?: string): void;
|
|
27
|
+
export declare function updateSpinner(label: string, phase?: string): void;
|
|
28
|
+
export declare function stopSpinner(): void;
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=ui.d.ts.map
|
package/dist/ui.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,CAAC;aACH,MAAM;eACJ,MAAM;gBACL,MAAM;aACT,MAAM;cACL,MAAM;cACN,MAAM;CACjB,CAAC;AAgBF,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAG1C;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAItC;AAED,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAGzC;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAItC;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAGtC;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAQtC;AAED,wBAAgB,KAAK,IAAI,IAAI,CAG5B;AAED,wBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAEnD;AA2BD,KAAK,aAAa,GAAG;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAwNF,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC,GAAG,IAAI,CAajE;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAM5D;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,SAAS,GAAG,IAAI,CAOhE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAWjE;AAED,wBAAgB,WAAW,IAAI,IAAI,CAIlC"}
|
package/dist/urls.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Notion public URL helpers */
|
|
2
|
+
export declare function isNotionSiteHost(hostname: string): boolean;
|
|
3
|
+
/** Extract 32-char page id (no dashes) from a Notion page URL path (not image/query ids). */
|
|
4
|
+
export declare function extractPageId(input: string): string | null;
|
|
5
|
+
/** Normalize a Notion block/page UUID to 32-char hex. */
|
|
6
|
+
export declare function normalizeBlockId(raw: string | null | undefined): string | null;
|
|
7
|
+
/** Canonical crawl key for a page (host + page id). */
|
|
8
|
+
export declare function pageKey(url: string): string | null;
|
|
9
|
+
/**
|
|
10
|
+
* Normalize a same-site Notion page URL for crawling:
|
|
11
|
+
* keep origin + pathname (drop query/hash that don't change the page).
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizePageUrl(raw: string, base?: string): string | null;
|
|
14
|
+
export declare function siteOrigin(url: string): string;
|
|
15
|
+
export declare function sameSite(a: string, b: string): boolean;
|
|
16
|
+
/** Safe filename stem from page title + id. */
|
|
17
|
+
export declare function pageFileStem(title: string, pageId: string): string;
|
|
18
|
+
export declare function extFromContentType(ct: string | null, url: string): string;
|
|
19
|
+
/** Detect real file type from magic bytes (avoids saving CF HTML as .png/.woff). */
|
|
20
|
+
export declare function sniffExt(body: Buffer): string | null;
|
|
21
|
+
//# sourceMappingURL=urls.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"urls.d.ts","sourceRoot":"","sources":["../src/urls.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAMhC,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAG1D;AAED,6FAA6F;AAC7F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyB1D;AAED,yDAAyD;AACzD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAI9E;AAED,uDAAuD;AACvD,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASlD;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAa1E;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAMtD;AAED,+CAA+C;AAC/C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CASlE;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CA4CzE;AAED,oFAAoF;AACpF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAqCpD"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Sync public Notion site → commit → GitHub Pages
|
|
2
|
+
#
|
|
3
|
+
# Works on ubuntu-latest with Chromium in a container.
|
|
4
|
+
# Set repository variable NOTION_URL to your public *.notion.site root.
|
|
5
|
+
|
|
6
|
+
name: Sync Notion
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
schedule:
|
|
11
|
+
- cron: "0 */4 * * *"
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: write
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
sync:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
timeout-minutes: 120
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- name: Setup Bun
|
|
25
|
+
uses: oven-sh/setup-bun@v2
|
|
26
|
+
with:
|
|
27
|
+
bun-version: latest
|
|
28
|
+
|
|
29
|
+
- name: Cache Chrome profile (Cloudflare cookies)
|
|
30
|
+
uses: actions/cache@v4
|
|
31
|
+
with:
|
|
32
|
+
path: ~/.notion-static-exporter/chrome-profile
|
|
33
|
+
key: notion-chrome-profile-${{ runner.os }}-v2
|
|
34
|
+
restore-keys: |
|
|
35
|
+
notion-chrome-profile-
|
|
36
|
+
|
|
37
|
+
- name: Install Google Chrome
|
|
38
|
+
id: chrome
|
|
39
|
+
uses: browser-actions/setup-chrome@v1
|
|
40
|
+
with:
|
|
41
|
+
chrome-version: stable
|
|
42
|
+
install-dependencies: true
|
|
43
|
+
|
|
44
|
+
- name: Scrape Notion
|
|
45
|
+
env:
|
|
46
|
+
NOTION_URL: ${{ vars.NOTION_URL }}
|
|
47
|
+
# Prefer the Chrome we just installed (Linux container safe)
|
|
48
|
+
PUPPETEER_EXECUTABLE_PATH: ${{ steps.chrome.outputs.chrome-path }}
|
|
49
|
+
# CI logs: phase lines every few seconds (no TTY spinner)
|
|
50
|
+
CI: true
|
|
51
|
+
run: |
|
|
52
|
+
if [ -z "$NOTION_URL" ]; then
|
|
53
|
+
echo "Set repository variable NOTION_URL to your public notion.site root"
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
echo "Chrome: $PUPPETEER_EXECUTABLE_PATH"
|
|
57
|
+
bunx --bun notion-static-exporter sync \
|
|
58
|
+
--url "$NOTION_URL" \
|
|
59
|
+
--out . \
|
|
60
|
+
--keep-cname \
|
|
61
|
+
--delay-ms 4000 \
|
|
62
|
+
--retries 4 \
|
|
63
|
+
--concurrency 2
|
|
64
|
+
|
|
65
|
+
- name: Commit & push
|
|
66
|
+
run: |
|
|
67
|
+
git config user.name "github-actions[bot]"
|
|
68
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
69
|
+
git add -A
|
|
70
|
+
# Keep local tooling / staging leftovers out of the Pages commit
|
|
71
|
+
git reset -- .notion-static-exporter 2>/dev/null || true
|
|
72
|
+
git reset -- '*.nsp-staging' '*.nsp-backup' 2>/dev/null || true
|
|
73
|
+
if git diff --staged --quiet; then
|
|
74
|
+
echo "No changes"
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
git commit -m "chore: sync notion static mirror"
|
|
78
|
+
git push
|