seemore 1.9.1 → 1.10.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/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 ["export-html", "export-pdf"];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seemore",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "Let AI write the Markdown. Let seemore show it better — zero config documentation framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 () { root.classList.toggle('dark'); });
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]');
@@ -308,7 +307,18 @@ const RUNTIME = `(function () {
308
307
  })();`;
309
308
 
310
309
  function buildExportHtml(article: Element, css: string, remoteLinks: string, favicons: string): string {
310
+ // The root's attributes ride along (lang, dir, whatever a theme added) minus the theme
311
+ // class next-themes resolved for *this* screen: THEME_INIT sets that from the reader's
312
+ // own OS when the file is opened. An emptied class attribute is dropped entirely.
311
313
  const attrs = Array.from(document.documentElement.attributes)
314
+ .map((attr) => {
315
+ if (attr.name !== 'class') return { name: attr.name, value: attr.value };
316
+ const kept = attr.value
317
+ .split(/\s+/)
318
+ .filter((name) => name !== '' && name !== 'dark' && name !== 'light');
319
+ return { name: attr.name, value: kept.join(' ') };
320
+ })
321
+ .filter((attr) => attr.name !== 'class' || attr.value !== '')
312
322
  .map((attr) => ` ${attr.name}="${escapeHtml(attr.value)}"`)
313
323
  .join('');
314
324
 
@@ -323,6 +333,7 @@ function buildExportHtml(article: Element, css: string, remoteLinks: string, fav
323
333
  `<title>${escapeHtml(document.title)}</title>`,
324
334
  description,
325
335
  '<meta name="generator" content="seemore">',
336
+ THEME_INIT,
326
337
  favicons,
327
338
  remoteLinks,
328
339
  // The guard is belt-and-braces: a stylesheet containing `</style>` would already be
@@ -386,31 +397,3 @@ export async function exportPageAsHtml(): Promise<void> {
386
397
  const favicons = await collectFavicons();
387
398
  download(exportFilename(), buildExportHtml(clone, await inlineCssUrls(css), remoteLinks, favicons));
388
399
  }
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, Download, Printer } from 'lucide-react';
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 { exportPageAsHtml, printPageAsPdf } from '../export/exportPage.js';
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, { label: string; icon: ReactNode; run: () => Promise<void> }> = {
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
- setOpen(false);
69
- void ACTIONS[id].run().catch((cause: unknown) => {
70
- const message = cause instanceof Error ? cause.message : String(cause);
71
- window.alert(`Could not run this action: ${message}`);
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 — the PDF path. Every pixel of chrome goes, the article gets the whole page,
687
- and nothing reader-consumed is ever split across a page break. */
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,
@@ -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 = ['export-html', 'export-pdf'] as const;
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
  }