seemore 1.8.7 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/cli/index.js +266 -18
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/package.json +1 -1
- package/src/app/entry.prerender.tsx +39 -1
- package/src/app/export/exportPage.ts +416 -0
- package/src/app/export/standalone.ts +137 -0
- package/src/app/export/themeToggle.ts +12 -0
- package/src/app/layout/DocsLayout.tsx +2 -0
- package/src/app/layout/PageActions.tsx +83 -0
- package/src/app/styles/globals.css +235 -0
- package/src/shared/types.ts +9 -0
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ type FeatureMap = Partial<Record<Feature, boolean>>;
|
|
|
11
11
|
/** @deprecated The array form, where a `!` prefix means off. Write {@link FeatureMap} instead. */
|
|
12
12
|
type FeatureFlag = Feature | `!${Feature}`;
|
|
13
13
|
type ResolvedFeatures = Record<Feature, boolean>;
|
|
14
|
+
/** The payload of `virtual:seemore/config`. */
|
|
15
|
+
/**
|
|
16
|
+
* The actions a page-actions button can hold, by id. Presence in the `actions` array is
|
|
17
|
+
* what enables an action; the array order is the menu order.
|
|
18
|
+
*/
|
|
19
|
+
declare const ACTION_IDS: readonly ["export-html", "export-pdf"];
|
|
20
|
+
type ActionId = (typeof ACTION_IDS)[number];
|
|
14
21
|
|
|
15
22
|
/** The CSS presets fumadocs-ui ships. We do not invent a token system. */
|
|
16
23
|
declare const THEMES: readonly ["neutral", "black", "catppuccin", "dusk", "ocean", "purple", "ruby", "solar", "aspen", "emerald", "vitepress", "shadcn"];
|
|
@@ -94,6 +101,10 @@ declare const configSchema: z.ZodObject<{
|
|
|
94
101
|
apiKey: z.ZodString;
|
|
95
102
|
indexName: z.ZodString;
|
|
96
103
|
}, z.core.$strip>]>>;
|
|
104
|
+
pageActions: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
105
|
+
"export-html": "export-html";
|
|
106
|
+
"export-pdf": "export-pdf";
|
|
107
|
+
}>>>;
|
|
97
108
|
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
98
109
|
}, z.core.$strip>;
|
|
99
110
|
/** What a user writes in `seemore.config.ts`. */
|
|
@@ -139,6 +150,7 @@ interface ResolvedSeemoreConfig {
|
|
|
139
150
|
text: string;
|
|
140
151
|
};
|
|
141
152
|
search: SearchConfig;
|
|
153
|
+
pageActions: ActionId[];
|
|
142
154
|
exclude: string[];
|
|
143
155
|
/** Directory the config was resolved from — relative paths in it hang off this. */
|
|
144
156
|
root: string;
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Writable } from 'node:stream';
|
|
2
2
|
import { StrictMode, type ReactNode } from 'react';
|
|
3
3
|
import { renderToPipeableStream } from 'react-dom/server';
|
|
4
|
-
import { RouterProvider, createMemoryRouter } from 'react-router';
|
|
4
|
+
import { MemoryRouter, RouterProvider, createMemoryRouter } from 'react-router';
|
|
5
5
|
import { config } from 'virtual:seemore/config';
|
|
6
6
|
import { toBasename, withBase } from '../shared/base.js';
|
|
7
7
|
import { ogImagePath } from '../shared/og.js';
|
|
8
|
+
import { mdxComponents } from './mdx/components.js';
|
|
8
9
|
import { createRouteObjects } from './router.js';
|
|
9
10
|
import { findRoute, preloadPage, routeEntries } from './lib/pages.js';
|
|
10
11
|
|
|
@@ -108,6 +109,43 @@ export function listRoutes(): string[] {
|
|
|
108
109
|
return routeEntries().map((entry) => entry.url);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** What `renderArticle` hands back: the content and the metadata a title bar needs. */
|
|
113
|
+
export interface ExportedArticle {
|
|
114
|
+
html: string;
|
|
115
|
+
title: string;
|
|
116
|
+
description?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The article alone — the MDX content, no layout, no sidebar, no chrome — for the
|
|
121
|
+
* single-page export. Rendering the component directly, rather than extracting the
|
|
122
|
+
* article from a full-page render, means no HTML parsing anywhere in the export path.
|
|
123
|
+
*
|
|
124
|
+
* Diagrams are absent here, as in every prerendered page (see `Mermaid.tsx`); the CLI
|
|
125
|
+
* export inlines a runtime that renders them when the file is opened.
|
|
126
|
+
*/
|
|
127
|
+
export async function renderArticle(url: string): Promise<ExportedArticle> {
|
|
128
|
+
const entry = findRoute(url);
|
|
129
|
+
if (entry === undefined) throw new Error(`No page at ${url}.`);
|
|
130
|
+
|
|
131
|
+
const page = await preloadPage(url);
|
|
132
|
+
if (page === undefined) throw new Error(`No page at ${url}.`);
|
|
133
|
+
|
|
134
|
+
const Content = page.default;
|
|
135
|
+
// A router is still required: content links go through react-router's `Link`, which
|
|
136
|
+
// reads the routing context. A memory router with just this page is the smallest one.
|
|
137
|
+
const { html, failures } = await renderToHtml(
|
|
138
|
+
<StrictMode>
|
|
139
|
+
<MemoryRouter initialEntries={[withBase(config.base, url)]} basename={toBasename(config.base)}>
|
|
140
|
+
<Content components={mdxComponents} />
|
|
141
|
+
</MemoryRouter>
|
|
142
|
+
</StrictMode>,
|
|
143
|
+
);
|
|
144
|
+
if (failures.length > 0) throw prerenderError(url, failures[0]);
|
|
145
|
+
|
|
146
|
+
return { html, title: entry.title, description: entry.description ?? undefined };
|
|
147
|
+
}
|
|
148
|
+
|
|
111
149
|
function head(url: string): string {
|
|
112
150
|
const entry = findRoute(url);
|
|
113
151
|
const title = entry === undefined || url === '/' ? config.title : `${entry.title} · ${config.title}`;
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { config } from 'virtual:seemore/config';
|
|
2
|
+
import { THEME_TOGGLE } from './themeToggle.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single-page export: the article you are reading, in one HTML file that renders
|
|
6
|
+
* offline.
|
|
7
|
+
*
|
|
8
|
+
* Everything happens client-side against the live DOM, which is what makes the same code
|
|
9
|
+
* work in the dev server, on a hosted static build, and inside an editor webview — none of
|
|
10
|
+
* the three owe the export a server. The page gives up three things in exchange: it runs
|
|
11
|
+
* after hydration (diagrams must already be rendered or renderable), it needs the network
|
|
12
|
+
* only for assets that are themselves remote, and it serialises the DOM as it is — site
|
|
13
|
+
* chrome never enters the file because only the article is taken.
|
|
14
|
+
*
|
|
15
|
+
* The same preparation drives the PDF path: print is the browser's own renderer, so the
|
|
16
|
+
* feature contributes a print stylesheet (see `globals.css`) and a light theme, not a PDF
|
|
17
|
+
* library.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Mermaid and D2 render on scroll-into-view; the export needs every diagram as SVG. */
|
|
21
|
+
async function prepareDiagrams(): Promise<void> {
|
|
22
|
+
const pending = Array.from(document.querySelectorAll<HTMLElement>('.seemore-mermaid, .seemore-d2')).filter(
|
|
23
|
+
(el) => el.querySelector('svg') === null && el.querySelector('.seemore-mermaid-error, .seemore-d2-error') === null,
|
|
24
|
+
);
|
|
25
|
+
if (pending.length === 0) return;
|
|
26
|
+
|
|
27
|
+
// Walking the page to wake each diagram moves the reader; put them back afterwards.
|
|
28
|
+
const scrollX = window.scrollX;
|
|
29
|
+
const scrollY = window.scrollY;
|
|
30
|
+
|
|
31
|
+
for (const el of pending) {
|
|
32
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
33
|
+
// A settled diagram shows either its SVG or the component's own error note. A timeout
|
|
34
|
+
// gives up on that one diagram rather than on the export: the site itself renders
|
|
35
|
+
// nothing better on a failed diagram, and the Markdown source `pre` it leaves behind
|
|
36
|
+
// is still honest content.
|
|
37
|
+
await waitFor(
|
|
38
|
+
() => el.querySelector('svg, .seemore-mermaid-error, .seemore-d2-error') !== null,
|
|
39
|
+
15_000,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
window.scrollTo(scrollX, scrollY);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const started = Date.now();
|
|
49
|
+
const tick = () => {
|
|
50
|
+
if (predicate() || Date.now() - started > timeoutMs) return resolve();
|
|
51
|
+
window.setTimeout(tick, 120);
|
|
52
|
+
};
|
|
53
|
+
tick();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every rule the page is styled by, as one string.
|
|
59
|
+
*
|
|
60
|
+
* Dev injects `<style>` tags (one per module, sometimes several identical ones across
|
|
61
|
+
* reloads); the build ships a single hashed stylesheet link. Same-origin sheets are inlined
|
|
62
|
+
* — a `file://` page cannot fetch them — and a sheet that is genuinely remote stays linked,
|
|
63
|
+
* the same treaty as remote images.
|
|
64
|
+
*/
|
|
65
|
+
async function collectCss(): Promise<{ css: string; remoteLinks: string }> {
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
const remote: string[] = [];
|
|
69
|
+
|
|
70
|
+
for (const style of document.querySelectorAll('style')) {
|
|
71
|
+
// The highlight rule is installed per-navigation and means nothing in a static file.
|
|
72
|
+
if (style.id === 'seemore-highlight-style') continue;
|
|
73
|
+
const text = style.textContent ?? '';
|
|
74
|
+
if (text.trim() === '' || seen.has(text)) continue;
|
|
75
|
+
seen.add(text);
|
|
76
|
+
parts.push(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')) {
|
|
80
|
+
let url: URL;
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(link.href, document.baseURI);
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (url.origin !== location.origin) {
|
|
87
|
+
remote.push(link.outerHTML);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const text = await fetch(url.href)
|
|
91
|
+
.then((response) => response.text())
|
|
92
|
+
.catch(() => undefined);
|
|
93
|
+
if (text === undefined || seen.has(text)) continue;
|
|
94
|
+
seen.add(text);
|
|
95
|
+
parts.push(text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { css: parts.join('\n'), remoteLinks: remote.join('\n') };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Rewrite same-origin `url(...)` references to data URIs; remote and broken ones stay. */
|
|
102
|
+
async function inlineCssUrls(css: string): Promise<string> {
|
|
103
|
+
const pattern = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
104
|
+
const replacements = new Map<string, string>();
|
|
105
|
+
|
|
106
|
+
for (const match of css.matchAll(pattern)) {
|
|
107
|
+
const raw = match[2] ?? '';
|
|
108
|
+
if (/^(?:data:|https?:)/i.test(raw) === false && !raw.startsWith('#')) {
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(raw, document.baseURI);
|
|
111
|
+
if (url.origin === location.origin) {
|
|
112
|
+
const blob = await fetch(url.href).then((response) => response.blob());
|
|
113
|
+
replacements.set(raw, await blobToDataUri(blob));
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Left as written: it did not resolve here, and a data URI we cannot build
|
|
117
|
+
// is no worse than the reference the live page already carries.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return css.replace(pattern, (whole, _quote: string, raw: string) => {
|
|
123
|
+
const data = replacements.get(raw);
|
|
124
|
+
return data === undefined ? whole : `url("${data}")`;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Same-origin images become data URIs; remote images keep their URLs, as decided. */
|
|
129
|
+
async function inlineImages(scope: Element): Promise<void> {
|
|
130
|
+
for (const img of scope.querySelectorAll('img')) {
|
|
131
|
+
const src = img.getAttribute('src');
|
|
132
|
+
if (src === null || src === '' || /^(?:data:|https?:)/i.test(src)) continue;
|
|
133
|
+
try {
|
|
134
|
+
const url = new URL(src, document.baseURI);
|
|
135
|
+
if (url.origin !== location.origin) continue;
|
|
136
|
+
const blob = await fetch(url.href).then((response) => {
|
|
137
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
138
|
+
return response.blob();
|
|
139
|
+
});
|
|
140
|
+
img.setAttribute('src', await blobToDataUri(blob));
|
|
141
|
+
img.removeAttribute('srcset');
|
|
142
|
+
} catch {
|
|
143
|
+
// Left as written: the reference was already broken on the live page, or points
|
|
144
|
+
// somewhere we agreed not to inline.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Favicons, inlined under the same treaty as everything else: data URIs ride along,
|
|
151
|
+
* same-origin files are fetched and turned into one, remote ones stay linked.
|
|
152
|
+
*/
|
|
153
|
+
async function collectFavicons(): Promise<string> {
|
|
154
|
+
const links: string[] = [];
|
|
155
|
+
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="icon"]')) {
|
|
156
|
+
try {
|
|
157
|
+
const url = new URL(link.href, document.baseURI);
|
|
158
|
+
if (url.protocol === 'data:' || url.origin !== location.origin) {
|
|
159
|
+
links.push(link.outerHTML);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const blob = await fetch(url.href).then((response) => response.blob());
|
|
163
|
+
links.push(link.outerHTML.replace(/href="[^"]*"/i, `href="${await blobToDataUri(blob)}"`));
|
|
164
|
+
} catch {
|
|
165
|
+
// One unreachable icon is no reason to skip the export.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return links.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function blobToDataUri(blob: Blob): Promise<string> {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const reader = new FileReader();
|
|
174
|
+
reader.onload = () => resolve(String(reader.result));
|
|
175
|
+
reader.onerror = () => reject(reader.error ?? new Error('Could not read the asset.'));
|
|
176
|
+
reader.readAsDataURL(blob);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* "On this page" for a file whose only navigation is itself: built from the headings the
|
|
182
|
+
* article already has (remark gave them ids), two levels deep, inserted after the title.
|
|
183
|
+
* Styled by `.seemore-export-toc`, which lives in the site stylesheet the file inlines.
|
|
184
|
+
*/
|
|
185
|
+
function buildExportToc(article: Element): Element | undefined {
|
|
186
|
+
const headings = Array.from(article.querySelectorAll('h2[id], h3[id]'));
|
|
187
|
+
if (headings.length === 0) return undefined;
|
|
188
|
+
|
|
189
|
+
const nav = document.createElement('nav');
|
|
190
|
+
nav.className = 'seemore-export-toc';
|
|
191
|
+
|
|
192
|
+
// A collapsible, not a heading pair: on narrow screens the block starts collapsed under
|
|
193
|
+
// the title, and the runtime opens it when the viewport is wide enough for the rail.
|
|
194
|
+
const details = document.createElement('details');
|
|
195
|
+
const summary = document.createElement('summary');
|
|
196
|
+
summary.textContent = 'On this page';
|
|
197
|
+
details.append(summary);
|
|
198
|
+
|
|
199
|
+
const top = document.createElement('ul');
|
|
200
|
+
details.append(top);
|
|
201
|
+
nav.append(details);
|
|
202
|
+
let nested: HTMLUListElement | undefined;
|
|
203
|
+
|
|
204
|
+
for (const heading of headings) {
|
|
205
|
+
const li = document.createElement('li');
|
|
206
|
+
const a = document.createElement('a');
|
|
207
|
+
a.href = `#${heading.id}`;
|
|
208
|
+
a.textContent = heading.textContent ?? '';
|
|
209
|
+
li.append(a);
|
|
210
|
+
|
|
211
|
+
if (heading.tagName === 'H2') {
|
|
212
|
+
top.append(li);
|
|
213
|
+
nested = document.createElement('ul');
|
|
214
|
+
li.append(nested);
|
|
215
|
+
} else {
|
|
216
|
+
(nested ?? top).append(li);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return nav;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The article is the only thing taken, but dev leaves editor affordances inside it. */
|
|
224
|
+
function cleanArticleForExport(article: Element): Element {
|
|
225
|
+
const clone = article.cloneNode(true) as Element;
|
|
226
|
+
for (const el of clone.querySelectorAll('.seemore-editor-layer, .seemore-editor, .seemore-editor-error')) {
|
|
227
|
+
el.remove();
|
|
228
|
+
}
|
|
229
|
+
for (const el of clone.querySelectorAll('[data-seemore-pos]')) {
|
|
230
|
+
el.removeAttribute('data-seemore-pos');
|
|
231
|
+
}
|
|
232
|
+
return clone;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function escapeHtml(text: string): string {
|
|
236
|
+
return text
|
|
237
|
+
.replaceAll('&', '&')
|
|
238
|
+
.replaceAll('<', '<')
|
|
239
|
+
.replaceAll('>', '>')
|
|
240
|
+
.replaceAll('"', '"');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The exported file's runtime: theme toggle, code copy, click-to-zoom — the three behaviors
|
|
245
|
+
* kept, at roughly a kilobyte instead of the site bundle. Handed to React in hydration on
|
|
246
|
+
* the live page; here each is three lines against the static DOM.
|
|
247
|
+
*
|
|
248
|
+
* Kept free of `</script>`-shaped sequences by construction: it is inlined verbatim.
|
|
249
|
+
*/
|
|
250
|
+
const RUNTIME = `(function () {
|
|
251
|
+
var root = document.documentElement;
|
|
252
|
+
var toggle = document.querySelector('.seemore-export-theme-toggle');
|
|
253
|
+
if (toggle) toggle.addEventListener('click', function () { root.classList.toggle('dark'); });
|
|
254
|
+
|
|
255
|
+
document.querySelectorAll('figure').forEach(function (figure) {
|
|
256
|
+
var button = figure.querySelector('button[aria-label]');
|
|
257
|
+
if (!button || !/copy/i.test(button.getAttribute('aria-label') || '')) return;
|
|
258
|
+
button.addEventListener('click', function () {
|
|
259
|
+
var code = figure.querySelector('pre, code');
|
|
260
|
+
if (code && navigator.clipboard) navigator.clipboard.writeText(code.textContent || '');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// The live site's TOC follows the reader with fumadocs' own scroll tracking; in the file,
|
|
265
|
+
// the plainest version of the same behaviour — last heading above the fold wins.
|
|
266
|
+
var tocLinks = [].slice.call(document.querySelectorAll('.seemore-export-toc a'));
|
|
267
|
+
if (tocLinks.length > 0) {
|
|
268
|
+
var byId = {};
|
|
269
|
+
tocLinks.forEach(function (a) { byId[(a.getAttribute('href') || '').slice(1)] = a; });
|
|
270
|
+
var points = Object.keys(byId).map(function (id) { return document.getElementById(id); }).filter(Boolean);
|
|
271
|
+
var sync = function () {
|
|
272
|
+
var current;
|
|
273
|
+
for (var i = 0; i < points.length; i++) {
|
|
274
|
+
// Headings carry scroll-margin-top (room for the live site's fixed header), and an
|
|
275
|
+
// anchor jump parks them exactly there — so "reached" means at or above their own
|
|
276
|
+
// margin line, not the raw viewport top.
|
|
277
|
+
var margin = parseFloat(getComputedStyle(points[i]).scrollMarginTop) || 0;
|
|
278
|
+
if (points[i].getBoundingClientRect().top - margin <= 48) current = points[i]; else break;
|
|
279
|
+
}
|
|
280
|
+
tocLinks.forEach(function (a) { a.removeAttribute('data-active'); });
|
|
281
|
+
if (current) byId[current.id].setAttribute('data-active', 'true');
|
|
282
|
+
};
|
|
283
|
+
window.addEventListener('scroll', sync, { passive: true });
|
|
284
|
+
sync();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Wide enough for the rail: open the collapsible so it reads as a list, not a disclosure.
|
|
288
|
+
var tocDetails = document.querySelector('.seemore-export-toc details');
|
|
289
|
+
if (tocDetails && window.matchMedia('(min-width: 1280px)').matches) tocDetails.open = true;
|
|
290
|
+
|
|
291
|
+
var main = document.querySelector('main');
|
|
292
|
+
var overlay = document.createElement('div');
|
|
293
|
+
overlay.className = 'seemore-export-overlay';
|
|
294
|
+
overlay.hidden = true;
|
|
295
|
+
var zoomed = document.createElement('img');
|
|
296
|
+
overlay.appendChild(zoomed);
|
|
297
|
+
document.body.appendChild(overlay);
|
|
298
|
+
function close() { overlay.hidden = true; zoomed.removeAttribute('src'); }
|
|
299
|
+
overlay.addEventListener('click', close);
|
|
300
|
+
document.addEventListener('keydown', function (event) { if (event.key === 'Escape') close(); });
|
|
301
|
+
if (main) main.querySelectorAll('img').forEach(function (img) {
|
|
302
|
+
if (img.closest('a')) return;
|
|
303
|
+
img.addEventListener('click', function () {
|
|
304
|
+
zoomed.setAttribute('src', img.currentSrc || img.src);
|
|
305
|
+
overlay.hidden = false;
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
})();`;
|
|
309
|
+
|
|
310
|
+
function buildExportHtml(article: Element, css: string, remoteLinks: string, favicons: string): string {
|
|
311
|
+
const attrs = Array.from(document.documentElement.attributes)
|
|
312
|
+
.map((attr) => ` ${attr.name}="${escapeHtml(attr.value)}"`)
|
|
313
|
+
.join('');
|
|
314
|
+
|
|
315
|
+
const description = config.description === undefined ? '' : `<meta name="description" content="${escapeHtml(config.description)}">`;
|
|
316
|
+
|
|
317
|
+
return [
|
|
318
|
+
'<!doctype html>',
|
|
319
|
+
`<html${attrs}>`,
|
|
320
|
+
'<head>',
|
|
321
|
+
'<meta charset="utf-8">',
|
|
322
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
323
|
+
`<title>${escapeHtml(document.title)}</title>`,
|
|
324
|
+
description,
|
|
325
|
+
'<meta name="generator" content="seemore">',
|
|
326
|
+
favicons,
|
|
327
|
+
remoteLinks,
|
|
328
|
+
// The guard is belt-and-braces: a stylesheet containing `</style>` would already be
|
|
329
|
+
// breaking the live page's own inline styles the same way.
|
|
330
|
+
`<style>\n${css.replaceAll('</style', '<\\/style')}</style>`,
|
|
331
|
+
'</head>',
|
|
332
|
+
'<body>',
|
|
333
|
+
`<main class="seemore-export-main">${article.outerHTML}</main>`,
|
|
334
|
+
THEME_TOGGLE,
|
|
335
|
+
`<script>${RUNTIME}</script>`,
|
|
336
|
+
'</body>',
|
|
337
|
+
'</html>',
|
|
338
|
+
]
|
|
339
|
+
.filter((line) => line !== '')
|
|
340
|
+
.join('\n');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The downloaded file's name: the page's own path segment, sanitised for a filename. */
|
|
344
|
+
function exportFilename(): string {
|
|
345
|
+
let path = location.pathname;
|
|
346
|
+
if (config.base !== '/') path = path.replace(config.base, '/');
|
|
347
|
+
const last = decodeURIComponent(path.replace(/\/+$/, '').split('/').at(-1) ?? '');
|
|
348
|
+
const slug = last === '' || last === '/' ? 'index' : last;
|
|
349
|
+
return `${slug.replace(/[^a-zA-Z0-9._-]+/g, '-')}.html`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function download(filename: string, html: string): void {
|
|
353
|
+
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
|
|
354
|
+
const a = document.createElement('a');
|
|
355
|
+
a.href = url;
|
|
356
|
+
a.download = filename;
|
|
357
|
+
document.body.append(a);
|
|
358
|
+
a.click();
|
|
359
|
+
a.remove();
|
|
360
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Export the page you are reading as one self-contained HTML file. */
|
|
364
|
+
export async function exportPageAsHtml(): Promise<void> {
|
|
365
|
+
const article = document.querySelector('main.seemore-main article');
|
|
366
|
+
if (article === null) throw new Error('There is nothing to export on this page.');
|
|
367
|
+
|
|
368
|
+
await prepareDiagrams();
|
|
369
|
+
|
|
370
|
+
const clone = cleanArticleForExport(article);
|
|
371
|
+
await inlineImages(clone);
|
|
372
|
+
|
|
373
|
+
const toc = buildExportToc(clone);
|
|
374
|
+
const heading = clone.querySelector('h1');
|
|
375
|
+
if (toc !== undefined) {
|
|
376
|
+
if (heading !== null && heading.nextElementSibling !== null) {
|
|
377
|
+
heading.nextElementSibling.before(toc);
|
|
378
|
+
} else if (heading !== null) {
|
|
379
|
+
heading.after(toc);
|
|
380
|
+
} else {
|
|
381
|
+
clone.prepend(toc);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { css, remoteLinks } = await collectCss();
|
|
386
|
+
const favicons = await collectFavicons();
|
|
387
|
+
download(exportFilename(), buildExportHtml(clone, await inlineCssUrls(css), remoteLinks, favicons));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Print the page you are reading — the browser's Save-as-PDF makes it a PDF.
|
|
392
|
+
*
|
|
393
|
+
* Print is always light, whatever the screen was showing: code blocks and diagrams are
|
|
394
|
+
* themed by CSS variables that flip with `dark`, and ink follows the light values.
|
|
395
|
+
*/
|
|
396
|
+
export async function printPageAsPdf(): Promise<void> {
|
|
397
|
+
await prepareDiagrams();
|
|
398
|
+
|
|
399
|
+
const root = document.documentElement;
|
|
400
|
+
const wasDark = root.classList.contains('dark');
|
|
401
|
+
if (!wasDark) {
|
|
402
|
+
window.print();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
root.classList.remove('dark');
|
|
407
|
+
const restore = () => {
|
|
408
|
+
root.classList.add('dark');
|
|
409
|
+
window.removeEventListener('afterprint', restore);
|
|
410
|
+
};
|
|
411
|
+
window.addEventListener('afterprint', restore);
|
|
412
|
+
window.print();
|
|
413
|
+
// Safari's dialog does not always fire `afterprint` when it is dismissed; a page left
|
|
414
|
+
// dark-less forever is worse than a late restore.
|
|
415
|
+
window.setTimeout(restore, 60_000);
|
|
416
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime inside a CLI-exported page.
|
|
3
|
+
*
|
|
4
|
+
* `seemore export` renders the article in node, where diagrams cannot render — that would
|
|
5
|
+
* mean a headless browser as an install dependency, which SPEC §16 rules out. This entry,
|
|
6
|
+
* bundled to an IIFE and inlined into the file, finishes the job when the page is opened:
|
|
7
|
+
* each diagram is rendered from the source `pre` the site's own components leave in
|
|
8
|
+
* prerendered output. It also binds the behaviors the export keeps — theme toggle, code
|
|
9
|
+
* copy, click-to-zoom — so the file behaves like the browser-exported one.
|
|
10
|
+
*
|
|
11
|
+
* The browser export ships a hand-written twin of the behavior half (the `RUNTIME` string
|
|
12
|
+
* in `exportPage.ts`): it needs no diagram half, because its diagrams are already SVG when
|
|
13
|
+
* the export runs. Keep the two in sync.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const isDark = (): boolean => document.documentElement.classList.contains('dark');
|
|
17
|
+
|
|
18
|
+
async function renderMermaid(container: HTMLElement, chart: string, index: number): Promise<void> {
|
|
19
|
+
const { default: mermaid } = await import('mermaid');
|
|
20
|
+
mermaid.initialize({ startOnLoad: false, theme: isDark() ? 'dark' : 'default', securityLevel: 'strict' });
|
|
21
|
+
const { svg } = await mermaid.render(`seemore-export-mermaid-${index}`, chart);
|
|
22
|
+
container.innerHTML = svg;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function renderD2(container: HTMLElement, chart: string, index: number): Promise<void> {
|
|
26
|
+
const { D2: D2Compiler } = await import('@terrastruct/d2');
|
|
27
|
+
const compiler = new D2Compiler();
|
|
28
|
+
const { diagram, renderOptions } = await compiler.compile(chart, {
|
|
29
|
+
options: { themeID: isDark() ? 300 : 0 },
|
|
30
|
+
});
|
|
31
|
+
container.innerHTML = await compiler.render(diagram, { ...renderOptions, salt: String(index) });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The site's own failure UI for a diagram that will not render. */
|
|
35
|
+
function fail(container: HTMLElement, className: string, cause: unknown): void {
|
|
36
|
+
const pre = document.createElement('pre');
|
|
37
|
+
pre.className = className;
|
|
38
|
+
pre.setAttribute('role', 'note');
|
|
39
|
+
pre.textContent = `Could not render this diagram: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
40
|
+
container.replaceChildren(pre);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function renderDiagrams(): Promise<void> {
|
|
44
|
+
const kinds = [
|
|
45
|
+
{ selector: '.seemore-mermaid', source: '.seemore-mermaid-source', error: 'seemore-mermaid-error', render: renderMermaid },
|
|
46
|
+
{ selector: '.seemore-d2', source: '.seemore-d2-source', error: 'seemore-d2-error', render: renderD2 },
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
for (const kind of kinds) {
|
|
50
|
+
const containers = Array.from(document.querySelectorAll<HTMLElement>(kind.selector));
|
|
51
|
+
for (const [index, container] of containers.entries()) {
|
|
52
|
+
if (container.querySelector('svg') !== null) continue;
|
|
53
|
+
const chart = container.querySelector(kind.source)?.textContent ?? '';
|
|
54
|
+
if (chart === '') continue;
|
|
55
|
+
try {
|
|
56
|
+
await kind.render(container, chart, index);
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
fail(container, kind.error, cause);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function bindBehaviors(): void {
|
|
65
|
+
const root = document.documentElement;
|
|
66
|
+
document.querySelector('.seemore-export-theme-toggle')?.addEventListener('click', () => {
|
|
67
|
+
root.classList.toggle('dark');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const figure of document.querySelectorAll('figure')) {
|
|
71
|
+
const button = figure.querySelector('button[aria-label]');
|
|
72
|
+
if (button === null || !/copy/i.test(button.getAttribute('aria-label') ?? '')) continue;
|
|
73
|
+
button.addEventListener('click', () => {
|
|
74
|
+
const code = figure.querySelector('pre, code');
|
|
75
|
+
if (code !== null) void navigator.clipboard?.writeText(code.textContent ?? '');
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The live site's TOC follows the reader with fumadocs' own scroll tracking; in the file,
|
|
80
|
+
// the plainest version of the same behaviour — last heading above the fold wins.
|
|
81
|
+
const tocLinks = Array.from(document.querySelectorAll<HTMLAnchorElement>('.seemore-export-toc a'));
|
|
82
|
+
if (tocLinks.length > 0) {
|
|
83
|
+
const byId = new Map<string, HTMLAnchorElement>();
|
|
84
|
+
for (const a of tocLinks) byId.set((a.getAttribute('href') ?? '').slice(1), a);
|
|
85
|
+
const points = Array.from(byId.keys())
|
|
86
|
+
.map((id) => document.getElementById(id))
|
|
87
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
88
|
+
const sync = (): void => {
|
|
89
|
+
let current: HTMLElement | undefined;
|
|
90
|
+
for (const point of points) {
|
|
91
|
+
// Headings carry `scroll-margin-top` (room for the live site's fixed header), and an
|
|
92
|
+
// anchor jump parks them exactly there — so "reached" means at or above their own
|
|
93
|
+
// margin line, not the raw viewport top.
|
|
94
|
+
const margin = Number.parseFloat(getComputedStyle(point).scrollMarginTop) || 0;
|
|
95
|
+
if (point.getBoundingClientRect().top - margin <= 48) current = point;
|
|
96
|
+
else break;
|
|
97
|
+
}
|
|
98
|
+
for (const a of tocLinks) a.removeAttribute('data-active');
|
|
99
|
+
if (current !== undefined) byId.get(current.id)?.setAttribute('data-active', 'true');
|
|
100
|
+
};
|
|
101
|
+
window.addEventListener('scroll', sync, { passive: true });
|
|
102
|
+
sync();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Wide enough for the rail: open the collapsible so it reads as a list, not a disclosure.
|
|
106
|
+
const tocDetails = document.querySelector<HTMLDetailsElement>('.seemore-export-toc details');
|
|
107
|
+
if (tocDetails && window.matchMedia('(min-width: 1280px)').matches) tocDetails.open = true;
|
|
108
|
+
|
|
109
|
+
const overlay = document.createElement('div');
|
|
110
|
+
overlay.className = 'seemore-export-overlay';
|
|
111
|
+
overlay.hidden = true;
|
|
112
|
+
const zoomed = document.createElement('img');
|
|
113
|
+
overlay.append(zoomed);
|
|
114
|
+
document.body.append(overlay);
|
|
115
|
+
const close = (): void => {
|
|
116
|
+
overlay.hidden = true;
|
|
117
|
+
zoomed.removeAttribute('src');
|
|
118
|
+
};
|
|
119
|
+
overlay.addEventListener('click', close);
|
|
120
|
+
document.addEventListener('keydown', (event) => {
|
|
121
|
+
if (event.key === 'Escape') close();
|
|
122
|
+
});
|
|
123
|
+
for (const img of document.querySelectorAll<HTMLImageElement>('main img')) {
|
|
124
|
+
if (img.closest('a') !== null) continue;
|
|
125
|
+
img.addEventListener('click', () => {
|
|
126
|
+
zoomed.setAttribute('src', img.currentSrc || img.src);
|
|
127
|
+
overlay.hidden = false;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function main(): void {
|
|
133
|
+
bindBehaviors();
|
|
134
|
+
void renderDiagrams();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The export's only chrome: a floating theme toggle, since the header is gone by design.
|
|
3
|
+
*
|
|
4
|
+
* The two glyphs ride as inline SVG with the site's own show/hide classes, so whichever
|
|
5
|
+
* runtime flips `dark` also flips the icon for free. Shared as a string because both
|
|
6
|
+
* export paths inline it verbatim — the browser export at run time, the CLI export at
|
|
7
|
+
* build time — and the two files must ship the same button.
|
|
8
|
+
*/
|
|
9
|
+
export const THEME_TOGGLE = `<button type="button" class="seemore-export-theme-toggle" aria-label="Toggle dark mode">
|
|
10
|
+
<svg class="seemore-icon-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
|
|
11
|
+
<svg class="seemore-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
|
|
12
|
+
</button>`;
|
|
@@ -19,6 +19,7 @@ import { useExternalLinkBridge } from './ExternalLinkBridge.js';
|
|
|
19
19
|
import { Header } from './Header.js';
|
|
20
20
|
import { Sidebar } from './Sidebar.js';
|
|
21
21
|
import { Breadcrumb } from './Breadcrumb.js';
|
|
22
|
+
import { PageActions } from './PageActions.js';
|
|
22
23
|
import { BackToTop, PageFooter, SiteFooter } from './Footer.js';
|
|
23
24
|
import { SelectionCopyButton } from './SelectionCopyButton.js';
|
|
24
25
|
import { IntegratedToc, Toc, TocProvider } from './Toc.js';
|
|
@@ -68,6 +69,7 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
|
68
69
|
|
|
69
70
|
<main className="seemore-main">
|
|
70
71
|
{feature('navigation.path') ? <Breadcrumb /> : undefined}
|
|
72
|
+
<PageActions />
|
|
71
73
|
<article className={editable ? 'seemore-article prose seemore-editable' : 'seemore-article prose'}>
|
|
72
74
|
<Content components={mdxComponents} />
|
|
73
75
|
{editable ? <InlineEditor key={entry.url} entry={entry} /> : undefined}
|