import { config } from 'virtual:seemore/config'; import { THEME_INIT, THEME_TOGGLE } from './themeToggle.js'; /** * The single-page export: the article you are reading, in one HTML file that renders * offline. * * Everything happens client-side against the live DOM, which is what makes the same code * work in the dev server, on a hosted static build, and inside an editor webview — none of * the three owe the export a server. The page gives up three things in exchange: it runs * after hydration (diagrams must already be rendered or renderable), it needs the network * only for assets that are themselves remote, and it serialises the DOM as it is — site * chrome never enters the file because only the article is taken. */ /** Mermaid and D2 render on scroll-into-view; the export needs every diagram as SVG. */ async function prepareDiagrams(): Promise { const pending = Array.from(document.querySelectorAll('.seemore-mermaid, .seemore-d2')).filter( (el) => el.querySelector('svg') === null && el.querySelector('.seemore-mermaid-error, .seemore-d2-error') === null, ); if (pending.length === 0) return; // Walking the page to wake each diagram moves the reader; put them back afterwards. const scrollX = window.scrollX; const scrollY = window.scrollY; for (const el of pending) { el.scrollIntoView({ behavior: 'instant', block: 'center' }); // A settled diagram shows either its SVG or the component's own error note. A timeout // gives up on that one diagram rather than on the export: the site itself renders // nothing better on a failed diagram, and the Markdown source `pre` it leaves behind // is still honest content. await waitFor( () => el.querySelector('svg, .seemore-mermaid-error, .seemore-d2-error') !== null, 15_000, ); } window.scrollTo(scrollX, scrollY); } function waitFor(predicate: () => boolean, timeoutMs: number): Promise { return new Promise((resolve) => { const started = Date.now(); const tick = () => { if (predicate() || Date.now() - started > timeoutMs) return resolve(); window.setTimeout(tick, 120); }; tick(); }); } /** * Every rule the page is styled by, as one string. * * Dev injects `` would already be // breaking the live page's own inline styles the same way. ``, '', '', `
${article.outerHTML}
`, THEME_TOGGLE, ``, '', '', ] .filter((line) => line !== '') .join('\n'); } /** The downloaded file's name: the page's own path segment, sanitised for a filename. */ function exportFilename(): string { let path = location.pathname; if (config.base !== '/') path = path.replace(config.base, '/'); const last = decodeURIComponent(path.replace(/\/+$/, '').split('/').at(-1) ?? ''); const slug = last === '' || last === '/' ? 'index' : last; return `${slug.replace(/[^a-zA-Z0-9._-]+/g, '-')}.html`; } function download(filename: string, html: string): void { const url = URL.createObjectURL(new Blob([html], { type: 'text/html' })); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.append(a); a.click(); a.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 10_000); } /** Export the page you are reading as one self-contained HTML file. */ export async function exportPageAsHtml(): Promise { const article = document.querySelector('main.seemore-main article'); if (article === null) throw new Error('There is nothing to export on this page.'); await prepareDiagrams(); const tabs = await captureTabPanels(); const clone = cleanArticleForExport(article); for (const [id, html] of tabs) { const panel = clone.querySelector(`[id="${CSS.escape(id)}"]`); if (panel !== null) panel.innerHTML = html; } await inlineImages(clone); const toc = buildExportToc(clone); const heading = clone.querySelector('h1'); if (toc !== undefined) { if (heading !== null && heading.nextElementSibling !== null) { heading.nextElementSibling.before(toc); } else if (heading !== null) { heading.after(toc); } else { clone.prepend(toc); } } const { css, remoteLinks } = await collectCss(); const favicons = await collectFavicons(); download(exportFilename(), buildExportHtml(clone, await inlineCssUrls(css), remoteLinks, favicons)); }