seemore 1.9.1 → 1.10.1
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 +3 -3
- package/dist/cli/index.js +51 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/package.json +1 -1
- package/src/app/export/copyMarkdown.ts +58 -0
- package/src/app/export/exportPage.ts +34 -34
- package/src/app/export/standalone.ts +2 -0
- package/src/app/export/themeToggle.ts +37 -0
- package/src/app/layout/PageActions.tsx +48 -11
- package/src/app/styles/globals.css +5 -3
- package/src/shared/types.ts +3 -1
package/dist/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ type ResolvedFeatures = Record<Feature, boolean>;
|
|
|
16
16
|
* The actions a page-actions button can hold, by id. Presence in the `actions` array is
|
|
17
17
|
* what enables an action; the array order is the menu order.
|
|
18
18
|
*/
|
|
19
|
-
declare const ACTION_IDS: readonly ["
|
|
19
|
+
declare const ACTION_IDS: readonly ["copy-markdown", "export-html"];
|
|
20
20
|
type ActionId = (typeof ACTION_IDS)[number];
|
|
21
21
|
|
|
22
22
|
/** The CSS presets fumadocs-ui ships. We do not invent a token system. */
|
|
@@ -102,8 +102,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
102
102
|
indexName: z.ZodString;
|
|
103
103
|
}, z.core.$strip>]>>;
|
|
104
104
|
pageActions: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
105
|
+
"copy-markdown": "copy-markdown";
|
|
105
106
|
"export-html": "export-html";
|
|
106
|
-
"export-pdf": "export-pdf";
|
|
107
107
|
}>>>;
|
|
108
108
|
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
109
109
|
}, z.core.$strip>;
|
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { preloadPage } from '../lib/pages.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copy the page you are reading as Markdown.
|
|
5
|
+
*
|
|
6
|
+
* The Markdown is not reconstructed from the DOM, and no second copy of the content is
|
|
7
|
+
* fetched: `remark-llms` stringifies the page during the MDX compile and writes the result
|
|
8
|
+
* to a named export (see `MARKDOWN_EXPORT` in `node/vite/mdx.ts`), which rides along in the
|
|
9
|
+
* same module the router already imports to render the page. By the time this runs that
|
|
10
|
+
* module is on screen, so `preloadPage` is a cache hit and the copy is synchronous in
|
|
11
|
+
* practice.
|
|
12
|
+
*
|
|
13
|
+
* The trade is payload: every page ships its own source alongside its compiled form. That
|
|
14
|
+
* buys a copy that matches what the author wrote — `.mdx` components included, as JSX —
|
|
15
|
+
* rather than a lossy walk over rendered HTML.
|
|
16
|
+
*/
|
|
17
|
+
export async function copyPageAsMarkdown(url: string): Promise<void> {
|
|
18
|
+
const page = await preloadPage(url);
|
|
19
|
+
const markdown = page?._markdown;
|
|
20
|
+
if (markdown === undefined || markdown.trim() === '') {
|
|
21
|
+
throw new Error('There is no Markdown source for this page.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Emptying the frontmatter node leaves the blank lines that followed it.
|
|
25
|
+
await writeToClipboard(`${markdown.trim()}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `navigator.clipboard` needs a secure context, which `file://` pages and some editor
|
|
30
|
+
* webviews are not. The deprecated `execCommand` path is the only thing that works there,
|
|
31
|
+
* and it needs a real selection over a live element, so the textarea has to be in the
|
|
32
|
+
* document and visible enough to focus.
|
|
33
|
+
*/
|
|
34
|
+
async function writeToClipboard(text: string): Promise<void> {
|
|
35
|
+
if (navigator.clipboard !== undefined && window.isSecureContext) {
|
|
36
|
+
try {
|
|
37
|
+
await navigator.clipboard.writeText(text);
|
|
38
|
+
return;
|
|
39
|
+
} catch {
|
|
40
|
+
// Permission refused or the document was not focused; fall through.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const area = document.createElement('textarea');
|
|
45
|
+
area.value = text;
|
|
46
|
+
area.setAttribute('readonly', '');
|
|
47
|
+
area.style.position = 'fixed';
|
|
48
|
+
area.style.top = '0';
|
|
49
|
+
area.style.opacity = '0';
|
|
50
|
+
document.body.append(area);
|
|
51
|
+
area.select();
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (!document.execCommand('copy')) throw new Error('The browser refused the copy.');
|
|
55
|
+
} finally {
|
|
56
|
+
area.remove();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { config } from 'virtual:seemore/config';
|
|
2
|
-
import { THEME_TOGGLE } from './themeToggle.js';
|
|
2
|
+
import { THEME_INIT, THEME_TOGGLE } from './themeToggle.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The single-page export: the article you are reading, in one HTML file that renders
|
|
@@ -11,10 +11,6 @@ import { THEME_TOGGLE } from './themeToggle.js';
|
|
|
11
11
|
* after hydration (diagrams must already be rendered or renderable), it needs the network
|
|
12
12
|
* only for assets that are themselves remote, and it serialises the DOM as it is — site
|
|
13
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
14
|
*/
|
|
19
15
|
|
|
20
16
|
/** Mermaid and D2 render on scroll-into-view; the export needs every diagram as SVG. */
|
|
@@ -250,7 +246,10 @@ function escapeHtml(text: string): string {
|
|
|
250
246
|
const RUNTIME = `(function () {
|
|
251
247
|
var root = document.documentElement;
|
|
252
248
|
var toggle = document.querySelector('.seemore-export-theme-toggle');
|
|
253
|
-
if (toggle) toggle.addEventListener('click', function () {
|
|
249
|
+
if (toggle) toggle.addEventListener('click', function () {
|
|
250
|
+
root.classList.toggle('dark');
|
|
251
|
+
if (window.__seemoreRememberTheme) window.__seemoreRememberTheme();
|
|
252
|
+
});
|
|
254
253
|
|
|
255
254
|
document.querySelectorAll('figure').forEach(function (figure) {
|
|
256
255
|
var button = figure.querySelector('button[aria-label]');
|
|
@@ -261,6 +260,23 @@ const RUNTIME = `(function () {
|
|
|
261
260
|
});
|
|
262
261
|
});
|
|
263
262
|
|
|
263
|
+
document.querySelectorAll('button[aria-label="Copy Anchor Link"]').forEach(function (button) {
|
|
264
|
+
var heading = button.closest('h1, h2, h3, h4, h5, h6');
|
|
265
|
+
var svg = button.querySelector('svg');
|
|
266
|
+
if (!heading || !heading.id || !navigator.clipboard) return;
|
|
267
|
+
var revert;
|
|
268
|
+
button.addEventListener('click', function () {
|
|
269
|
+
var url = new URL(window.location.href);
|
|
270
|
+
url.hash = heading.id;
|
|
271
|
+
navigator.clipboard.writeText(url.href);
|
|
272
|
+
if (!svg) return;
|
|
273
|
+
clearTimeout(revert);
|
|
274
|
+
var original = svg.innerHTML;
|
|
275
|
+
svg.innerHTML = '<polyline points="20 6 9 17 4 12"></polyline>';
|
|
276
|
+
revert = setTimeout(function () { svg.innerHTML = original; }, 1500);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
264
280
|
// The live site's TOC follows the reader with fumadocs' own scroll tracking; in the file,
|
|
265
281
|
// the plainest version of the same behaviour — last heading above the fold wins.
|
|
266
282
|
var tocLinks = [].slice.call(document.querySelectorAll('.seemore-export-toc a'));
|
|
@@ -308,7 +324,18 @@ const RUNTIME = `(function () {
|
|
|
308
324
|
})();`;
|
|
309
325
|
|
|
310
326
|
function buildExportHtml(article: Element, css: string, remoteLinks: string, favicons: string): string {
|
|
327
|
+
// The root's attributes ride along (lang, dir, whatever a theme added) minus the theme
|
|
328
|
+
// class next-themes resolved for *this* screen: THEME_INIT sets that from the reader's
|
|
329
|
+
// own OS when the file is opened. An emptied class attribute is dropped entirely.
|
|
311
330
|
const attrs = Array.from(document.documentElement.attributes)
|
|
331
|
+
.map((attr) => {
|
|
332
|
+
if (attr.name !== 'class') return { name: attr.name, value: attr.value };
|
|
333
|
+
const kept = attr.value
|
|
334
|
+
.split(/\s+/)
|
|
335
|
+
.filter((name) => name !== '' && name !== 'dark' && name !== 'light');
|
|
336
|
+
return { name: attr.name, value: kept.join(' ') };
|
|
337
|
+
})
|
|
338
|
+
.filter((attr) => attr.name !== 'class' || attr.value !== '')
|
|
312
339
|
.map((attr) => ` ${attr.name}="${escapeHtml(attr.value)}"`)
|
|
313
340
|
.join('');
|
|
314
341
|
|
|
@@ -323,6 +350,7 @@ function buildExportHtml(article: Element, css: string, remoteLinks: string, fav
|
|
|
323
350
|
`<title>${escapeHtml(document.title)}</title>`,
|
|
324
351
|
description,
|
|
325
352
|
'<meta name="generator" content="seemore">',
|
|
353
|
+
THEME_INIT,
|
|
326
354
|
favicons,
|
|
327
355
|
remoteLinks,
|
|
328
356
|
// The guard is belt-and-braces: a stylesheet containing `</style>` would already be
|
|
@@ -386,31 +414,3 @@ export async function exportPageAsHtml(): Promise<void> {
|
|
|
386
414
|
const favicons = await collectFavicons();
|
|
387
415
|
download(exportFilename(), buildExportHtml(clone, await inlineCssUrls(css), remoteLinks, favicons));
|
|
388
416
|
}
|
|
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
|
-
}
|
|
@@ -65,6 +65,8 @@ function bindBehaviors(): void {
|
|
|
65
65
|
const root = document.documentElement;
|
|
66
66
|
document.querySelector('.seemore-export-theme-toggle')?.addEventListener('click', () => {
|
|
67
67
|
root.classList.toggle('dark');
|
|
68
|
+
// Set by THEME_INIT in the head: pins the file to this choice against the OS.
|
|
69
|
+
(window as { __seemoreRememberTheme?: () => void }).__seemoreRememberTheme?.();
|
|
68
70
|
});
|
|
69
71
|
|
|
70
72
|
for (const figure of document.querySelectorAll('figure')) {
|
|
@@ -10,3 +10,40 @@ export const THEME_TOGGLE = `<button type="button" class="seemore-export-theme-t
|
|
|
10
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
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
12
|
</button>`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The exported file's theme, decided when the reader opens it rather than when the export
|
|
16
|
+
* ran.
|
|
17
|
+
*
|
|
18
|
+
* The live site resolves `system` through next-themes and leaves a literal `dark`/`light`
|
|
19
|
+
* class on `<html>`; serialising that would hand every reader the exporter's OS setting
|
|
20
|
+
* forever. So the export strips the resolved class and ships this instead: the reader's own
|
|
21
|
+
* `prefers-color-scheme` on load, their toggle choice if they have made one, and a listener
|
|
22
|
+
* that keeps following the OS until they do.
|
|
23
|
+
*
|
|
24
|
+
* Inlined in `<head>` by both export paths so it runs before first paint — a class set from
|
|
25
|
+
* the body would flash the wrong theme first. `localStorage` throws outright on some
|
|
26
|
+
* `file://` origins, which is why every access is guarded; an unreadable store just means
|
|
27
|
+
* the file follows the OS, which is the sane default anyway.
|
|
28
|
+
*/
|
|
29
|
+
export const THEME_INIT = `<script>(function () {
|
|
30
|
+
var KEY = 'seemore-export-theme';
|
|
31
|
+
var root = document.documentElement;
|
|
32
|
+
var saved = null;
|
|
33
|
+
try { saved = localStorage.getItem(KEY); } catch (e) {}
|
|
34
|
+
var query = window.matchMedia('(prefers-color-scheme: dark)');
|
|
35
|
+
var apply = function (dark) { root.classList.toggle('dark', dark); };
|
|
36
|
+
|
|
37
|
+
apply(saved === 'dark' || saved === 'light' ? saved === 'dark' : query.matches);
|
|
38
|
+
query.addEventListener('change', function (event) {
|
|
39
|
+
var override = null;
|
|
40
|
+
try { override = localStorage.getItem(KEY); } catch (e) {}
|
|
41
|
+
if (override !== 'dark' && override !== 'light') apply(event.matches);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The toggle lives in the body, so its click handler is bound by each export's runtime;
|
|
45
|
+
// this only records the choice, which is what pins the file against the OS from then on.
|
|
46
|
+
window.__seemoreRememberTheme = function () {
|
|
47
|
+
try { localStorage.setItem(KEY, root.classList.contains('dark') ? 'dark' : 'light'); } catch (e) {}
|
|
48
|
+
};
|
|
49
|
+
})();</script>`;
|
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
import { ChevronDown,
|
|
3
|
+
import { Check, ChevronDown, Copy, Download } from 'lucide-react';
|
|
4
4
|
import { config } from 'virtual:seemore/config';
|
|
5
5
|
import type { ActionId } from '../../shared/types.js';
|
|
6
|
-
import {
|
|
6
|
+
import { copyPageAsMarkdown } from '../export/copyMarkdown.js';
|
|
7
|
+
import { exportPageAsHtml } from '../export/exportPage.js';
|
|
8
|
+
import { useRouteUrl } from '../router.js';
|
|
9
|
+
|
|
10
|
+
interface Action {
|
|
11
|
+
label: string;
|
|
12
|
+
icon: ReactNode;
|
|
13
|
+
run: (url: string) => Promise<void>;
|
|
14
|
+
/** Shown in place of the label for a moment afterwards, for an action with no visible result. */
|
|
15
|
+
done?: string;
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* The actions the button can hold, keyed by the id a config's `pageActions` array names.
|
|
10
20
|
* A new action is a new id in `ACTION_IDS`, one entry here, and the menu picks it up —
|
|
11
21
|
* no component changes.
|
|
12
22
|
*/
|
|
13
|
-
const ACTIONS: Record<ActionId,
|
|
23
|
+
const ACTIONS: Record<ActionId, Action> = {
|
|
24
|
+
'copy-markdown': {
|
|
25
|
+
label: 'Copy as Markdown',
|
|
26
|
+
icon: <Copy aria-hidden="true" />,
|
|
27
|
+
run: copyPageAsMarkdown,
|
|
28
|
+
done: 'Copied',
|
|
29
|
+
},
|
|
14
30
|
'export-html': { label: 'Export as HTML', icon: <Download aria-hidden="true" />, run: exportPageAsHtml },
|
|
15
|
-
'export-pdf': { label: 'Export as PDF', icon: <Printer aria-hidden="true" />, run: printPageAsPdf },
|
|
16
31
|
};
|
|
17
32
|
|
|
18
33
|
/**
|
|
@@ -22,7 +37,20 @@ const ACTIONS: Record<ActionId, { label: string; icon: ReactNode; run: () => Pro
|
|
|
22
37
|
*/
|
|
23
38
|
export function PageActions() {
|
|
24
39
|
const [open, setOpen] = useState(false);
|
|
40
|
+
const [done, setDone] = useState<ActionId>();
|
|
25
41
|
const root = useRef<HTMLDivElement>(null);
|
|
42
|
+
const url = useRouteUrl();
|
|
43
|
+
|
|
44
|
+
useEffect(() => setDone(undefined), [url]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (done === undefined) return;
|
|
48
|
+
const timer = window.setTimeout(() => {
|
|
49
|
+
setDone(undefined);
|
|
50
|
+
setOpen(false);
|
|
51
|
+
}, 1200);
|
|
52
|
+
return () => window.clearTimeout(timer);
|
|
53
|
+
}, [done]);
|
|
26
54
|
|
|
27
55
|
useEffect(() => {
|
|
28
56
|
if (!open) return;
|
|
@@ -65,15 +93,24 @@ export function PageActions() {
|
|
|
65
93
|
role="menuitem"
|
|
66
94
|
className="seemore-page-actions-item"
|
|
67
95
|
onClick={() => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
96
|
+
const action = ACTIONS[id];
|
|
97
|
+
// An action that confirms in place keeps the menu open long enough to be
|
|
98
|
+
// seen; the rest produce a file or a dialog and can close immediately.
|
|
99
|
+
if (action.done === undefined) setOpen(false);
|
|
100
|
+
void action
|
|
101
|
+
.run(url)
|
|
102
|
+
.then(() => {
|
|
103
|
+
if (action.done !== undefined) setDone(id);
|
|
104
|
+
})
|
|
105
|
+
.catch((cause: unknown) => {
|
|
106
|
+
setOpen(false);
|
|
107
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
108
|
+
window.alert(`Could not run this action: ${message}`);
|
|
109
|
+
});
|
|
73
110
|
}}
|
|
74
111
|
>
|
|
75
|
-
{ACTIONS[id].icon}
|
|
76
|
-
{ACTIONS[id].label}
|
|
112
|
+
{done === id ? <Check aria-hidden="true" /> : ACTIONS[id].icon}
|
|
113
|
+
{done === id ? ACTIONS[id].done : ACTIONS[id].label}
|
|
77
114
|
</button>
|
|
78
115
|
))}
|
|
79
116
|
</div>
|
|
@@ -487,8 +487,10 @@
|
|
|
487
487
|
@apply size-4;
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
+
/* `top-full` is load-bearing: without it the menu keeps its static position, which in this
|
|
491
|
+
flex row is beside the trigger rather than under it, so it opens over its own button. */
|
|
490
492
|
.seemore-page-actions-menu {
|
|
491
|
-
@apply absolute end-0 z-50 mt-1.5 min-w-44 rounded-lg border border-fd-border bg-fd-background p-1 shadow-lg;
|
|
493
|
+
@apply absolute end-0 top-full z-50 mt-1.5 min-w-44 rounded-lg border border-fd-border bg-fd-background p-1 shadow-lg;
|
|
492
494
|
}
|
|
493
495
|
|
|
494
496
|
.seemore-page-actions-item {
|
|
@@ -683,8 +685,8 @@
|
|
|
683
685
|
object-fit: contain;
|
|
684
686
|
}
|
|
685
687
|
|
|
686
|
-
/* Printing
|
|
687
|
-
|
|
688
|
+
/* Printing. Every pixel of chrome goes, the article gets the whole page, and nothing
|
|
689
|
+
reader-consumed is ever split across a page break. */
|
|
688
690
|
@media print {
|
|
689
691
|
.seemore-header,
|
|
690
692
|
.seemore-sidebar,
|
package/src/shared/types.ts
CHANGED
|
@@ -48,7 +48,7 @@ export type ClientSearchConfig =
|
|
|
48
48
|
* The actions a page-actions button can hold, by id. Presence in the `actions` array is
|
|
49
49
|
* what enables an action; the array order is the menu order.
|
|
50
50
|
*/
|
|
51
|
-
export const ACTION_IDS = ['
|
|
51
|
+
export const ACTION_IDS = ['copy-markdown', 'export-html'] as const;
|
|
52
52
|
|
|
53
53
|
export type ActionId = (typeof ACTION_IDS)[number];
|
|
54
54
|
|
|
@@ -90,4 +90,6 @@ export interface PageModule {
|
|
|
90
90
|
default: ComponentType<{ components?: Record<string, unknown> }>;
|
|
91
91
|
/** Exported by fumadocs' `rehype-toc`. */
|
|
92
92
|
toc?: TocEntry[];
|
|
93
|
+
/** The page as Markdown, written at compile time by `remark-llms`. */
|
|
94
|
+
_markdown?: string;
|
|
93
95
|
}
|