svelte-docsmith 0.4.0 → 0.5.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.
Files changed (61) hide show
  1. package/dist/config.d.ts +27 -0
  2. package/dist/config.js +9 -1
  3. package/dist/generate-llms.d.ts +31 -0
  4. package/dist/generate-llms.js +63 -0
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.js +5 -0
  7. package/dist/llms.d.ts +11 -0
  8. package/dist/llms.js +4 -0
  9. package/dist/normalize-path.d.ts +6 -0
  10. package/dist/normalize-path.js +8 -0
  11. package/dist/sitemap.d.ts +27 -0
  12. package/dist/sitemap.js +44 -0
  13. package/dist/ui/layouts/copy-page-menu.svelte +113 -0
  14. package/dist/ui/layouts/copy-page-menu.svelte.d.ts +9 -0
  15. package/dist/ui/layouts/docs-header.svelte +1 -1
  16. package/dist/ui/layouts/docs-mobile-header.svelte +1 -1
  17. package/dist/ui/layouts/docs-shell.svelte +83 -9
  18. package/dist/ui/layouts/docs-shell.svelte.d.ts +6 -0
  19. package/dist/ui/layouts/docs-sidebar.svelte +3 -2
  20. package/dist/ui/layouts/prev-next-nav.svelte +9 -2
  21. package/dist/ui/layouts/prev-next-nav.svelte.d.ts +1 -0
  22. package/dist/ui/layouts/seo-head.svelte +2 -1
  23. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  24. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +4 -0
  25. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte +44 -0
  26. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte.d.ts +9 -0
  27. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-content.svelte +31 -0
  28. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-content.svelte.d.ts +10 -0
  29. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  30. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-group-heading.svelte.d.ts +8 -0
  31. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-group.svelte +7 -0
  32. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-group.svelte.d.ts +4 -0
  33. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-item.svelte +27 -0
  34. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-item.svelte.d.ts +8 -0
  35. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-label.svelte +27 -0
  36. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-label.svelte.d.ts +8 -0
  37. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  38. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-portal.svelte.d.ts +3 -0
  39. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte +16 -0
  40. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +4 -0
  41. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-radio-item.svelte +34 -0
  42. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-radio-item.svelte.d.ts +4 -0
  43. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  44. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-separator.svelte.d.ts +4 -0
  45. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-shortcut.svelte +23 -0
  46. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-shortcut.svelte.d.ts +5 -0
  47. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-sub-content.svelte +20 -0
  48. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-sub-content.svelte.d.ts +4 -0
  49. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  50. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-sub-trigger.svelte.d.ts +7 -0
  51. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  52. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-sub.svelte.d.ts +3 -0
  53. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  54. package/dist/ui/shadcn/dropdown-menu/dropdown-menu-trigger.svelte.d.ts +4 -0
  55. package/dist/ui/shadcn/dropdown-menu/dropdown-menu.svelte +7 -0
  56. package/dist/ui/shadcn/dropdown-menu/dropdown-menu.svelte.d.ts +3 -0
  57. package/dist/ui/shadcn/dropdown-menu/index.d.ts +18 -0
  58. package/dist/ui/shadcn/dropdown-menu/index.js +18 -0
  59. package/dist/vite.d.ts +8 -1
  60. package/dist/vite.js +60 -3
  61. package/package.json +6 -3
package/dist/config.d.ts CHANGED
@@ -29,6 +29,13 @@ export type DocsmithConfig = {
29
29
  url?: string;
30
30
  /** Default social-share image (absolute URL, or a path resolved against `url`). */
31
31
  ogImage?: string;
32
+ /**
33
+ * Base URL for the "Edit this page" link, pointing at the directory that maps
34
+ * to your app's working directory in the repo, e.g.
35
+ * `https://github.com/you/repo/edit/main/apps/docs`. DocsShell appends each
36
+ * page's source path. Omit to hide the edit link.
37
+ */
38
+ editUrl?: string;
32
39
  /** Optional GitHub URL; renders a link in the header when set. */
33
40
  github?: string;
34
41
  /** Optional version string, shown in the header (e.g. your library version). */
@@ -68,6 +75,10 @@ export type DocsContentItem = {
68
75
  section?: string;
69
76
  order?: number;
70
77
  description?: string;
78
+ /** Source file path relative to the app cwd, for the "Edit this page" link. */
79
+ sourcePath?: string;
80
+ /** Last git commit date (ISO) for the page's source file. */
81
+ lastUpdated?: string;
71
82
  /**
72
83
  * The page's `h2`/`h3` headings, extracted at build time by the `docsmith()`
73
84
  * vite plugin, so `DocsShell` can render the in-page TOC server-side. The
@@ -96,6 +107,22 @@ export type SearchDoc = {
96
107
  /** Plain-text body: prose and headings, code and markup removed. */
97
108
  text: string;
98
109
  };
110
+ /**
111
+ * One page's record for LLM-facing output, emitted at build time by the
112
+ * `docsmith()` vite plugin as the `svelte-docsmith/llms` virtual module. Feeds
113
+ * `generateLlmsTxt` (an index) and `generateLlmsFullTxt` (the full corpus).
114
+ */
115
+ export type LlmsDoc = {
116
+ path: string;
117
+ title: string;
118
+ section?: string;
119
+ /** Sidebar order within the section, so LLM output follows reading order. */
120
+ order?: number;
121
+ description?: string;
122
+ /** The page's markdown body (frontmatter and `<script>`/`<style>` removed,
123
+ * headings and code kept), with the title prepended as an `h1`. */
124
+ content: string;
125
+ };
99
126
  /** A single sidebar link. */
100
127
  export type NavItem = {
101
128
  title: string;
package/dist/config.js CHANGED
@@ -10,7 +10,15 @@ export function defineConfig(config) {
10
10
  if (typeof config.title !== 'string' || config.title.trim() === '') {
11
11
  throw new Error('[svelte-docsmith] config.title is required — the site title shown in the sidebar header.');
12
12
  }
13
- for (const key of ['github', 'version', 'logo', 'description', 'url', 'ogImage']) {
13
+ for (const key of [
14
+ 'github',
15
+ 'version',
16
+ 'logo',
17
+ 'description',
18
+ 'url',
19
+ 'ogImage',
20
+ 'editUrl'
21
+ ]) {
14
22
  if (config[key] !== undefined && typeof config[key] !== 'string') {
15
23
  throw new Error(`[svelte-docsmith] config.${key} must be a string when set.`);
16
24
  }
@@ -0,0 +1,31 @@
1
+ import type { LlmsDoc } from './config.js';
2
+ /** Site-level info for the LLM files: title, optional description, and origin. */
3
+ export type LlmsSite = {
4
+ title: string;
5
+ description?: string;
6
+ /** Absolute origin, e.g. `https://my-docs.dev`, used to make links absolute. */
7
+ origin?: string;
8
+ };
9
+ /**
10
+ * Build an [llms.txt](https://llmstxt.org) index: the site title and
11
+ * description, then a sectioned list of links to each page. Wire it into a
12
+ * `src/routes/llms.txt/+server.ts` over the `svelte-docsmith/llms` module.
13
+ *
14
+ * ```ts
15
+ * import { docs } from 'svelte-docsmith/llms';
16
+ * import { generateLlmsTxt } from 'svelte-docsmith';
17
+ *
18
+ * export const prerender = true;
19
+ * export function GET() {
20
+ * const body = generateLlmsTxt({ title: 'My Docs', origin: 'https://my-docs.dev' }, docs);
21
+ * return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
22
+ * }
23
+ * ```
24
+ */
25
+ export declare function generateLlmsTxt(site: LlmsSite, docs: LlmsDoc[]): string;
26
+ /**
27
+ * Build `llms-full.txt`: the site header followed by the full markdown of every
28
+ * page, so an LLM can ingest the entire documentation in one request. Same
29
+ * wiring as {@link generateLlmsTxt}, at `src/routes/llms-full.txt/+server.ts`.
30
+ */
31
+ export declare function generateLlmsFullTxt(site: LlmsSite, docs: LlmsDoc[]): string;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Group docs by section and put both the sections and the docs within them in
3
+ * sidebar reading order: items by their `order`, sections by the smallest
4
+ * `order` they contain. Mirrors `navFromContent` so the LLM index matches the
5
+ * on-page navigation instead of falling back to path-alphabetical order.
6
+ */
7
+ function bySection(docs) {
8
+ const groups = new Map();
9
+ for (const doc of docs) {
10
+ const section = doc.section ?? 'Docs';
11
+ const order = doc.order ?? 0;
12
+ const group = groups.get(section) ?? { minOrder: Infinity, items: [] };
13
+ group.items.push(doc);
14
+ group.minOrder = Math.min(group.minOrder, order);
15
+ groups.set(section, group);
16
+ }
17
+ return [...groups.entries()]
18
+ .sort((a, b) => a[1].minOrder - b[1].minOrder)
19
+ .map(([section, group]) => [
20
+ section,
21
+ group.items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
22
+ ]);
23
+ }
24
+ /**
25
+ * Build an [llms.txt](https://llmstxt.org) index: the site title and
26
+ * description, then a sectioned list of links to each page. Wire it into a
27
+ * `src/routes/llms.txt/+server.ts` over the `svelte-docsmith/llms` module.
28
+ *
29
+ * ```ts
30
+ * import { docs } from 'svelte-docsmith/llms';
31
+ * import { generateLlmsTxt } from 'svelte-docsmith';
32
+ *
33
+ * export const prerender = true;
34
+ * export function GET() {
35
+ * const body = generateLlmsTxt({ title: 'My Docs', origin: 'https://my-docs.dev' }, docs);
36
+ * return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
37
+ * }
38
+ * ```
39
+ */
40
+ export function generateLlmsTxt(site, docs) {
41
+ const base = (site.origin ?? '').replace(/\/$/, '');
42
+ const out = [`# ${site.title}`];
43
+ if (site.description)
44
+ out.push('', `> ${site.description}`);
45
+ for (const [section, items] of bySection(docs)) {
46
+ out.push('', `## ${section}`, '');
47
+ for (const doc of items) {
48
+ const suffix = doc.description ? `: ${doc.description}` : '';
49
+ out.push(`- [${doc.title}](${base}${doc.path})${suffix}`);
50
+ }
51
+ }
52
+ return out.join('\n') + '\n';
53
+ }
54
+ /**
55
+ * Build `llms-full.txt`: the site header followed by the full markdown of every
56
+ * page, so an LLM can ingest the entire documentation in one request. Same
57
+ * wiring as {@link generateLlmsTxt}, at `src/routes/llms-full.txt/+server.ts`.
58
+ */
59
+ export function generateLlmsFullTxt(site, docs) {
60
+ const header = site.description ? `# ${site.title}\n\n> ${site.description}` : `# ${site.title}`;
61
+ const ordered = bySection(docs).flatMap(([, items]) => items);
62
+ return [header, ...ordered.map((doc) => doc.content.trim())].join('\n\n') + '\n';
63
+ }
package/dist/index.d.ts CHANGED
@@ -18,5 +18,7 @@ export { default as FileTree } from './ui/file-tree.svelte';
18
18
  export { default as FileTreeItem } from './ui/file-tree-item.svelte';
19
19
  export { default as PropsTable } from './ui/props-table.svelte';
20
20
  export { default as Prop } from './ui/prop.svelte';
21
- export { defineConfig, type DocsmithConfig, type DocsContentItem, type SearchDoc } from './config.js';
21
+ export { defineConfig, type DocsmithConfig, type DocsContentItem, type SearchDoc, type LlmsDoc } from './config.js';
22
22
  export { createSearchEngine, type SearchEngine, type SearchResult } from './search/create-search.js';
23
+ export { generateSitemap, type SitemapEntry } from './sitemap.js';
24
+ export { generateLlmsTxt, generateLlmsFullTxt, type LlmsSite } from './generate-llms.js';
package/dist/index.js CHANGED
@@ -28,3 +28,8 @@ export { defineConfig } from './config.js';
28
28
  // Search engine (framework-agnostic; for building a custom search UI over the
29
29
  // generated `svelte-docsmith/search` index)
30
30
  export { createSearchEngine } from './search/create-search.js';
31
+ // Sitemap (framework-agnostic; wire into a `sitemap.xml/+server.ts`)
32
+ export { generateSitemap } from './sitemap.js';
33
+ // llms.txt (framework-agnostic; wire into `llms.txt/+server.ts` and
34
+ // `llms-full.txt/+server.ts` over the generated `svelte-docsmith/llms` index)
35
+ export { generateLlmsTxt, generateLlmsFullTxt } from './generate-llms.js';
package/dist/llms.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `svelte-docsmith/llms` — the generated LLM content index.
3
+ *
4
+ * At build time the `docsmith()` Vite plugin (in `vite.config.ts`) intercepts
5
+ * this import and replaces it with your doc pages' titles, sections, and full
6
+ * markdown bodies. This file is only the fallback that runs when the plugin is
7
+ * missing — it exists so the import type-checks and fails with a clear message
8
+ * instead of a mystery.
9
+ */
10
+ import type { LlmsDoc } from './config.js';
11
+ export declare const docs: LlmsDoc[];
package/dist/llms.js ADDED
@@ -0,0 +1,4 @@
1
+ export const docs = [];
2
+ throw new Error("[svelte-docsmith] 'svelte-docsmith/llms' requires the docsmith() plugin. " +
3
+ "Add it to your vite.config: import { docsmith } from 'svelte-docsmith/vite'; " +
4
+ 'plugins: [docsmith(), tailwindcss(), sveltekit()]');
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Strip a trailing slash so route matching is consistent regardless of the
3
+ * consumer's SvelteKit `trailingSlash` setting: `/docs/intro/` and `/docs/intro`
4
+ * resolve to the same content entry. The root stays `/`.
5
+ */
6
+ export declare function normalizePath(pathname: string): string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Strip a trailing slash so route matching is consistent regardless of the
3
+ * consumer's SvelteKit `trailingSlash` setting: `/docs/intro/` and `/docs/intro`
4
+ * resolve to the same content entry. The root stays `/`.
5
+ */
6
+ export function normalizePath(pathname) {
7
+ return pathname.replace(/\/+$/, '') || '/';
8
+ }
@@ -0,0 +1,27 @@
1
+ /** One entry in a sitemap: a page path (joined to the origin) and optional last-modified date. */
2
+ export type SitemapEntry = {
3
+ /** Absolute path, e.g. `/docs/intro`. Joined to the origin. */
4
+ path: string;
5
+ /** Last-modified date; an ISO string or `YYYY-MM-DD`. Only the date is emitted. */
6
+ lastmod?: string;
7
+ };
8
+ /**
9
+ * Build a sitemap.xml body from a list of pages. Framework-agnostic: wire it
10
+ * into a SvelteKit `src/routes/sitemap.xml/+server.ts` that maps the generated
11
+ * `svelte-docsmith/content` index (plus any non-doc routes) into entries.
12
+ *
13
+ * ```ts
14
+ * import { docs } from 'svelte-docsmith/content';
15
+ * import { generateSitemap } from 'svelte-docsmith';
16
+ *
17
+ * export const prerender = true;
18
+ * export function GET() {
19
+ * const body = generateSitemap('https://my-docs.dev', [
20
+ * { path: '/' },
21
+ * ...docs.map((d) => ({ path: d.path, lastmod: d.lastUpdated }))
22
+ * ]);
23
+ * return new Response(body, { headers: { 'content-type': 'application/xml' } });
24
+ * }
25
+ * ```
26
+ */
27
+ export declare function generateSitemap(origin: string, entries: SitemapEntry[]): string;
@@ -0,0 +1,44 @@
1
+ const escapeXml = (s) => s.replace(/[&<>'"]/g, (c) => {
2
+ switch (c) {
3
+ case '&':
4
+ return '&amp;';
5
+ case '<':
6
+ return '&lt;';
7
+ case '>':
8
+ return '&gt;';
9
+ case "'":
10
+ return '&apos;';
11
+ default:
12
+ return '&quot;';
13
+ }
14
+ });
15
+ /**
16
+ * Build a sitemap.xml body from a list of pages. Framework-agnostic: wire it
17
+ * into a SvelteKit `src/routes/sitemap.xml/+server.ts` that maps the generated
18
+ * `svelte-docsmith/content` index (plus any non-doc routes) into entries.
19
+ *
20
+ * ```ts
21
+ * import { docs } from 'svelte-docsmith/content';
22
+ * import { generateSitemap } from 'svelte-docsmith';
23
+ *
24
+ * export const prerender = true;
25
+ * export function GET() {
26
+ * const body = generateSitemap('https://my-docs.dev', [
27
+ * { path: '/' },
28
+ * ...docs.map((d) => ({ path: d.path, lastmod: d.lastUpdated }))
29
+ * ]);
30
+ * return new Response(body, { headers: { 'content-type': 'application/xml' } });
31
+ * }
32
+ * ```
33
+ */
34
+ export function generateSitemap(origin, entries) {
35
+ const base = origin.replace(/\/$/, '');
36
+ const urls = entries
37
+ .map(({ path, lastmod }) => {
38
+ const loc = `\t\t<loc>${escapeXml(base + path)}</loc>`;
39
+ const mod = lastmod ? `\n\t\t<lastmod>${lastmod.slice(0, 10)}</lastmod>` : '';
40
+ return `\t<url>\n${loc}${mod}\n\t</url>`;
41
+ })
42
+ .join('\n');
43
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
44
+ }
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ import { browser } from '$app/environment';
3
+ import { Button } from '../shadcn/button/index.js';
4
+ import * as DropdownMenu from '../shadcn/dropdown-menu/index.js';
5
+ import Check from '@lucide/svelte/icons/check';
6
+ import ChevronDown from '@lucide/svelte/icons/chevron-down';
7
+ import Copy from '@lucide/svelte/icons/copy';
8
+ import FileText from '@lucide/svelte/icons/file-text';
9
+ import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right';
10
+
11
+ const {
12
+ path,
13
+ origin = ''
14
+ }: {
15
+ /** The current page's normalized pathname, e.g. `/docs/intro`. */
16
+ path: string;
17
+ /** Absolute site origin for the AI deep links; falls back to `location.origin`. */
18
+ origin?: string;
19
+ } = $props();
20
+
21
+ // Convention: each page's markdown lives at `<path>.md`, served by the
22
+ // consumer's catch-all endpoint over the `svelte-docsmith/llms` index.
23
+ const mdHref = $derived(`${path}.md`);
24
+ const base = $derived((origin || (browser ? location.origin : '')).replace(/\/$/, ''));
25
+ const absoluteMd = $derived(`${base}${mdHref}`);
26
+ const prompt = $derived(`Read ${absoluteMd} so I can ask questions about this page.`);
27
+ const chatgptHref = $derived(`https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt)}`);
28
+ const claudeHref = $derived(`https://claude.ai/new?q=${encodeURIComponent(prompt)}`);
29
+
30
+ let copied = $state(false);
31
+ let timer: ReturnType<typeof setTimeout>;
32
+
33
+ async function copyPage() {
34
+ try {
35
+ const res = await fetch(mdHref);
36
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
37
+ const text = await res.text();
38
+ // `navigator.clipboard` is undefined on insecure origins and can reject
39
+ // without permission; fail quietly so the button never throws.
40
+ await navigator.clipboard.writeText(text);
41
+ copied = true;
42
+ clearTimeout(timer);
43
+ timer = setTimeout(() => (copied = false), 1500);
44
+ } catch (error) {
45
+ console.warn('[svelte-docsmith] copy page failed', error);
46
+ }
47
+ }
48
+ </script>
49
+
50
+ <div class="border-border bg-background inline-flex items-center rounded-md border shadow-xs">
51
+ <Button
52
+ variant="ghost"
53
+ size="sm"
54
+ onclick={copyPage}
55
+ class="rounded-r-none"
56
+ aria-label="Copy this page as Markdown"
57
+ >
58
+ {#if copied}
59
+ <Check class="text-primary" />
60
+ Copied
61
+ {:else}
62
+ <Copy />
63
+ Copy page
64
+ {/if}
65
+ </Button>
66
+ <div class="bg-border h-5 w-px" aria-hidden="true"></div>
67
+ <DropdownMenu.Root>
68
+ <DropdownMenu.Trigger>
69
+ {#snippet child({ props })}
70
+ <Button
71
+ variant="ghost"
72
+ size="sm"
73
+ class="rounded-l-none px-2"
74
+ aria-label="More page actions"
75
+ {...props}
76
+ >
77
+ <ChevronDown />
78
+ </Button>
79
+ {/snippet}
80
+ </DropdownMenu.Trigger>
81
+ <DropdownMenu.Content align="end" class="w-56">
82
+ <DropdownMenu.Item onSelect={copyPage}>
83
+ <Copy />
84
+ Copy page
85
+ </DropdownMenu.Item>
86
+ <DropdownMenu.Item>
87
+ {#snippet child({ props })}
88
+ <a href={mdHref} target="_blank" rel="noopener" {...props}>
89
+ <FileText />
90
+ View as Markdown
91
+ </a>
92
+ {/snippet}
93
+ </DropdownMenu.Item>
94
+ <DropdownMenu.Separator />
95
+ <DropdownMenu.Item>
96
+ {#snippet child({ props })}
97
+ <a href={chatgptHref} target="_blank" rel="noopener" {...props}>
98
+ <ArrowUpRight />
99
+ Open in ChatGPT
100
+ </a>
101
+ {/snippet}
102
+ </DropdownMenu.Item>
103
+ <DropdownMenu.Item>
104
+ {#snippet child({ props })}
105
+ <a href={claudeHref} target="_blank" rel="noopener" {...props}>
106
+ <ArrowUpRight />
107
+ Open in Claude
108
+ </a>
109
+ {/snippet}
110
+ </DropdownMenu.Item>
111
+ </DropdownMenu.Content>
112
+ </DropdownMenu.Root>
113
+ </div>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ /** The current page's normalized pathname, e.g. `/docs/intro`. */
3
+ path: string;
4
+ /** Absolute site origin for the AI deep links; falls back to `location.origin`. */
5
+ origin?: string;
6
+ };
7
+ declare const CopyPageMenu: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type CopyPageMenu = ReturnType<typeof CopyPageMenu>;
9
+ export default CopyPageMenu;
@@ -49,7 +49,7 @@
49
49
 
50
50
  <div class="ml-auto flex items-center gap-2">
51
51
  {#if config.nav?.length}
52
- <nav class="mr-1 flex items-center gap-1">
52
+ <nav aria-label="Main" class="mr-1 flex items-center gap-1">
53
53
  {#each config.nav as link (link.href)}
54
54
  <a
55
55
  href={link.href}
@@ -79,7 +79,7 @@
79
79
  </div>
80
80
  <ScrollArea class="my-4 h-[calc(100vh-8rem)] pb-10 pl-2">
81
81
  {#if config.nav?.length}
82
- <nav class="flex flex-col gap-0.5 px-5 pb-1">
82
+ <nav aria-label="Main" class="flex flex-col gap-0.5 px-5 pb-1">
83
83
  {#each config.nav as link (link.href)}
84
84
  <a
85
85
  href={link.href}
@@ -9,6 +9,7 @@
9
9
  } from '../../config.js';
10
10
  import { createToc } from '../../toc/index.js';
11
11
  import { createSearchState } from '../../search/context.svelte.js';
12
+ import { normalizePath } from '../../normalize-path.js';
12
13
  import Search from '../search.svelte';
13
14
  import BackgroundPattern from '../background-pattern.svelte';
14
15
  import ThemeProvider from '../theme-provider.svelte';
@@ -18,7 +19,9 @@
18
19
  import DocsSidebar from './docs-sidebar.svelte';
19
20
  import PrevNextNav from './prev-next-nav.svelte';
20
21
  import Breadcrumbs, { type Crumb } from './breadcrumbs.svelte';
22
+ import CopyPageMenu from './copy-page-menu.svelte';
21
23
  import SeoHead from './seo-head.svelte';
24
+ import SquarePen from '@lucide/svelte/icons/square-pen';
22
25
  import TableOfContents from '../table-of-contents.svelte';
23
26
  import type { Snippet } from 'svelte';
24
27
 
@@ -32,6 +35,7 @@
32
35
  search,
33
36
  seo,
34
37
  pattern = false,
38
+ copyPage = false,
35
39
  layout = 'docs'
36
40
  }: {
37
41
  config: DocsmithConfig;
@@ -57,6 +61,12 @@
57
61
  footer?: Snippet;
58
62
  /** Render the decorative grid-and-glow page background. */
59
63
  pattern?: boolean;
64
+ /**
65
+ * Show the "Copy page" split button on doc pages: copy the page as
66
+ * Markdown, view the raw `.md`, or open it in ChatGPT / Claude. Requires a
67
+ * catch-all `<path>.md` endpoint over the `svelte-docsmith/llms` index.
68
+ */
69
+ copyPage?: boolean;
60
70
  /**
61
71
  * `docs` (default): the three-column shell — sidebar, content, in-page TOC.
62
72
  * `page`: full-bleed content with the same header/footer/background but no
@@ -75,12 +85,34 @@
75
85
 
76
86
  const nav = $derived(navFromContent(content));
77
87
 
78
- // The content entry for the current route drives the SEO title/description.
79
- const currentEntry = $derived(content.find((item) => item.path === page.url.pathname));
88
+ // Match content by a trailing-slash-normalized path, so `/docs/intro/` and
89
+ // `/docs/intro` resolve to the same page regardless of the app's trailingSlash.
90
+ const pathname = $derived(normalizePath(page.url.pathname));
91
+
92
+ // The content entry for the current route drives the SEO title/description,
93
+ // the "Edit this page" link, and the "Last updated" stamp.
94
+ const currentEntry = $derived(content.find((item) => item.path === pathname));
95
+
96
+ const editHref = $derived(
97
+ config.editUrl && currentEntry?.sourcePath
98
+ ? config.editUrl.replace(/\/$/, '') + '/' + currentEntry.sourcePath
99
+ : undefined
100
+ );
101
+ const lastUpdatedLabel = $derived.by(() => {
102
+ const iso = currentEntry?.lastUpdated;
103
+ if (!iso) return undefined;
104
+ const d = new Date(iso);
105
+ return Number.isNaN(d.getTime())
106
+ ? undefined
107
+ : new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short', day: 'numeric' }).format(
108
+ d
109
+ );
110
+ });
111
+ const showFooterMeta = $derived(Boolean(editHref || lastUpdatedLabel));
80
112
 
81
113
  // Ordered flat page list drives the prev/next links.
82
114
  const flatNav = $derived(nav.flatMap((group) => group.items));
83
- const pageIndex = $derived(flatNav.findIndex((item) => item.url === page.url.pathname));
115
+ const pageIndex = $derived(flatNav.findIndex((item) => item.url === pathname));
84
116
  const prev = $derived(pageIndex > 0 ? flatNav[pageIndex - 1] : undefined);
85
117
  const next = $derived(
86
118
  pageIndex >= 0 && pageIndex < flatNav.length - 1 ? flatNav[pageIndex + 1] : undefined
@@ -89,7 +121,7 @@
89
121
 
90
122
  // Breadcrumb trail: the current page's sidebar group, then the page itself.
91
123
  const currentGroup = $derived(
92
- nav.find((group) => group.items.some((item) => item.url === page.url.pathname))
124
+ nav.find((group) => group.items.some((item) => item.url === pathname))
93
125
  );
94
126
  const breadcrumbs = $derived.by(() => {
95
127
  const crumbs: Crumb[] = [];
@@ -109,7 +141,7 @@
109
141
  // Server-rendered TOC (headings extracted at build time into the content
110
142
  // index) so the list is present on first paint. Once the client engine has
111
143
  // scanned the DOM, its items take over (scroll-spy + edge-case accuracy).
112
- const pageToc = $derived(content.find((item) => item.path === page.url.pathname)?.toc ?? []);
144
+ const pageToc = $derived(content.find((item) => item.path === pathname)?.toc ?? []);
113
145
  const tocItems = $derived(toc.items.length ? toc.items : pageToc);
114
146
  </script>
115
147
 
@@ -120,6 +152,14 @@
120
152
  />
121
153
 
122
154
  <div class="relative isolate flex min-h-screen flex-col">
155
+ <!-- Keyboard skip link: first focusable element, visible only on focus. -->
156
+ <a
157
+ href="#main-content"
158
+ class="bg-primary text-primary-foreground focus-visible:ring-ring sr-only z-50 rounded-md px-3 py-2 text-sm font-medium focus-visible:not-sr-only focus-visible:absolute focus-visible:top-3 focus-visible:left-3 focus-visible:ring-2"
159
+ >
160
+ Skip to content
161
+ </a>
162
+
123
163
  <!-- Owns light/dark for the whole app — consumers never wire mode-watcher. -->
124
164
  <ThemeProvider />
125
165
 
@@ -138,7 +178,7 @@
138
178
  {#if layout === 'page'}
139
179
  <DocsMobileHeader {config} {logo} {actions} />
140
180
 
141
- <main class="flex-1">
181
+ <main id="main-content" tabindex="-1" class="flex-1">
142
182
  {@render children()}
143
183
  </main>
144
184
  {:else}
@@ -155,10 +195,44 @@
155
195
  <div class="mx-auto flex w-full max-w-7xl flex-1 gap-12 px-4 md:px-6 lg:px-8 lg:pt-10">
156
196
  <DocsSidebar {nav} />
157
197
 
158
- <main bind:this={contentEl} class="min-w-0 flex-1 py-6 lg:py-0">
159
- <Breadcrumbs items={breadcrumbs} />
198
+ <main
199
+ bind:this={contentEl}
200
+ id="main-content"
201
+ tabindex="-1"
202
+ class="min-w-0 flex-1 py-6 lg:py-0"
203
+ >
204
+ <div class="flex items-start justify-between gap-4">
205
+ <Breadcrumbs items={breadcrumbs} />
206
+ {#if copyPage && currentEntry}
207
+ <div class="shrink-0">
208
+ <CopyPageMenu path={pathname} origin={config.url ?? ''} />
209
+ </div>
210
+ {/if}
211
+ </div>
160
212
  {@render children()}
161
- <PrevNextNav {prev} {next} />
213
+
214
+ {#if showFooterMeta}
215
+ <div
216
+ class="text-muted-foreground border-border mt-10 flex flex-wrap items-center gap-3 border-t pt-6 text-sm"
217
+ >
218
+ {#if editHref}
219
+ <a
220
+ href={editHref}
221
+ target="_blank"
222
+ rel="noopener noreferrer"
223
+ class="hover:text-foreground inline-flex items-center gap-1.5 transition-colors"
224
+ >
225
+ <SquarePen class="size-4" />
226
+ Edit this page
227
+ </a>
228
+ {/if}
229
+ {#if lastUpdatedLabel}
230
+ <span class="ml-auto">Last updated: {lastUpdatedLabel}</span>
231
+ {/if}
232
+ </div>
233
+ {/if}
234
+
235
+ <PrevNextNav {prev} {next} bordered={!showFooterMeta} />
162
236
  </main>
163
237
 
164
238
  <!-- Reserve the TOC column so its content filling in after hydration
@@ -27,6 +27,12 @@ type $$ComponentProps = {
27
27
  footer?: Snippet;
28
28
  /** Render the decorative grid-and-glow page background. */
29
29
  pattern?: boolean;
30
+ /**
31
+ * Show the "Copy page" split button on doc pages: copy the page as
32
+ * Markdown, view the raw `.md`, or open it in ChatGPT / Claude. Requires a
33
+ * catch-all `<path>.md` endpoint over the `svelte-docsmith/llms` index.
34
+ */
35
+ copyPage?: boolean;
30
36
  /**
31
37
  * `docs` (default): the three-column shell — sidebar, content, in-page TOC.
32
38
  * `page`: full-bleed content with the same header/footer/background but no
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { page } from '$app/state';
3
3
  import { cn } from '../../shadcn.js';
4
+ import { normalizePath } from '../../normalize-path.js';
4
5
  import type { NavGroup } from '../../config.js';
5
6
 
6
7
  const { nav, class: className = '' }: { nav: NavGroup[]; class?: string } = $props();
@@ -8,7 +9,7 @@
8
9
 
9
10
  <aside class={cn('sticky top-24 hidden h-screen w-56 shrink-0 bg-transparent lg:block', className)}>
10
11
  <div class="max-h-[calc(100vh-10rem)] overflow-y-auto px-4">
11
- <nav class="space-y-6">
12
+ <nav aria-label="Documentation" class="space-y-6">
12
13
  {#each nav as group (group.title)}
13
14
  <div class="space-y-3">
14
15
  <h4 class="text-foreground px-2 text-sm font-semibold">{group.title}</h4>
@@ -19,7 +20,7 @@
19
20
  href={item.url}
20
21
  class={cn(
21
22
  'hover:text-primary hover:bg-primary/20 block rounded-md px-2 py-1.5 text-sm transition-colors',
22
- page.url.pathname === item.url
23
+ normalizePath(page.url.pathname) === item.url
23
24
  ? 'text-primary bg-primary/20 font-medium'
24
25
  : 'text-muted-foreground'
25
26
  )}
@@ -1,11 +1,18 @@
1
1
  <script lang="ts">
2
2
  import type { NavItem } from '../../config.js';
3
3
 
4
- const { prev, next }: { prev?: NavItem; next?: NavItem } = $props();
4
+ const {
5
+ prev,
6
+ next,
7
+ bordered = true
8
+ }: { prev?: NavItem; next?: NavItem; bordered?: boolean } = $props();
5
9
  </script>
6
10
 
7
11
  {#if prev || next}
8
- <nav class="border-border mt-8 flex justify-between gap-4 border-t pt-8">
12
+ <nav
13
+ aria-label="Pagination"
14
+ class="flex justify-between gap-4 {bordered ? 'border-border mt-8 border-t pt-8' : 'mt-6'}"
15
+ >
9
16
  {#if prev}
10
17
  <a
11
18
  href={prev.url}