svelte-docsmith 0.9.0 → 0.11.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.
Files changed (58) hide show
  1. package/bin/svelte-docsmith.mjs +16 -0
  2. package/dist/buildtime/archive.d.ts +31 -0
  3. package/dist/buildtime/archive.js +50 -0
  4. package/dist/buildtime/archives.d.ts +18 -0
  5. package/dist/buildtime/archives.js +43 -0
  6. package/dist/buildtime/cli/archive-version.d.ts +18 -0
  7. package/dist/buildtime/cli/archive-version.js +104 -0
  8. package/dist/buildtime/cli/error.d.ts +9 -0
  9. package/dist/buildtime/cli/error.js +9 -0
  10. package/dist/buildtime/cli/run.d.ts +13 -0
  11. package/dist/buildtime/cli/run.js +62 -0
  12. package/dist/buildtime/{vite/git.d.ts → git.d.ts} +1 -1
  13. package/dist/buildtime/{vite/git.js → git.js} +15 -3
  14. package/dist/buildtime/markdown-source.d.ts +31 -0
  15. package/dist/buildtime/markdown-source.js +90 -0
  16. package/dist/buildtime/paths.d.ts +7 -0
  17. package/dist/buildtime/paths.js +15 -0
  18. package/dist/buildtime/vite/collect.d.ts +4 -3
  19. package/dist/buildtime/vite/collect.js +32 -13
  20. package/dist/buildtime/vite/extract.js +9 -35
  21. package/dist/buildtime/vite/frontmatter.js +4 -3
  22. package/dist/buildtime/vite/index.d.ts +9 -0
  23. package/dist/buildtime/vite/index.js +16 -4
  24. package/dist/buildtime/vite/releases.js +1 -1
  25. package/dist/components/changelog/changelog-entry.svelte +6 -1
  26. package/dist/components/chrome/copy-button.svelte +3 -2
  27. package/dist/components/chrome/search.svelte +19 -4
  28. package/dist/components/chrome/search.svelte.d.ts +4 -1
  29. package/dist/components/chrome/version-banner.svelte +67 -0
  30. package/dist/components/chrome/version-banner.svelte.d.ts +10 -0
  31. package/dist/components/chrome/version-switcher.svelte +53 -0
  32. package/dist/components/chrome/version-switcher.svelte.d.ts +8 -0
  33. package/dist/components/docs-page-context.d.ts +20 -0
  34. package/dist/components/docs-page-context.js +41 -0
  35. package/dist/components/layouts/docs-header.svelte +12 -3
  36. package/dist/components/layouts/docs-mobile-header.svelte +8 -0
  37. package/dist/components/layouts/docs-shell.svelte +81 -62
  38. package/dist/components/layouts/docs-shell.svelte.d.ts +11 -2
  39. package/dist/components/layouts/error-page.svelte +23 -3
  40. package/dist/components/layouts/error-page.svelte.d.ts +8 -2
  41. package/dist/components/layouts/seo-head.svelte +8 -1
  42. package/dist/components/layouts/seo-head.svelte.d.ts +2 -0
  43. package/dist/core/changelog.d.ts +4 -1
  44. package/dist/core/content.d.ts +15 -1
  45. package/dist/core/docs-page.d.ts +85 -0
  46. package/dist/core/docs-page.js +72 -0
  47. package/dist/core/index.d.ts +2 -0
  48. package/dist/core/index.js +2 -0
  49. package/dist/core/version.d.ts +116 -0
  50. package/dist/core/version.js +186 -0
  51. package/dist/fallbacks/content.d.ts +2 -0
  52. package/dist/fallbacks/content.js +1 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.js +5 -0
  55. package/dist/search/create-search.d.ts +8 -2
  56. package/dist/search/create-search.js +11 -3
  57. package/dist/theme.css +21 -0
  58. package/package.json +7 -2
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { listPageFiles } from './pages.js';
4
4
  import { parseFrontmatter } from './frontmatter.js';
5
5
  import { extractLlmsContent, extractSearchText, extractToc, readingMinutes } from './extract.js';
6
- import { lastCommitDate } from './git.js';
6
+ import { lastCommitDate } from '../git.js';
7
7
  /**
8
8
  * Read a page's `section` frontmatter: a string is one level, an array is a
9
9
  * nested group path. Non-string array members are dropped; anything else is
@@ -38,7 +38,7 @@ function sectionKey(value) {
38
38
  * frontmatter, derived URL, and title so every index (nav, search, llms) can be
39
39
  * built from a single read of each file.
40
40
  */
41
- function* eachTitledPage(contentDir, routesDir) {
41
+ function* eachTitledPage(contentDir, routesDir, versions) {
42
42
  for (const file of listPageFiles(contentDir)) {
43
43
  const source = fs.readFileSync(file, 'utf-8');
44
44
  const front = parseFrontmatter(source, file);
@@ -46,7 +46,20 @@ function* eachTitledPage(contentDir, routesDir) {
46
46
  continue;
47
47
  const dir = path.dirname(file);
48
48
  const url = '/' + path.relative(routesDir, dir).split(path.sep).join('/');
49
- yield { source, front, url, title: front.title, file };
49
+ yield { source, front, url, title: front.title, file, version: versionOf(dir) };
50
+ }
51
+ /**
52
+ * A page belongs to an archived version when its first directory segment under
53
+ * the content dir is that archive's id; everything else is the current
54
+ * version, which lives unprefixed at the docs root. No versions ⇒ undefined,
55
+ * which keeps an unversioned site's index byte-for-byte what it was.
56
+ */
57
+ function versionOf(dir) {
58
+ if (!versions)
59
+ return undefined;
60
+ const firstSegment = path.relative(contentDir, dir).split(path.sep)[0];
61
+ const archived = versions.archived?.find((v) => v.id === firstSegment);
62
+ return archived ? archived.id : versions.current.id;
50
63
  }
51
64
  }
52
65
  /**
@@ -55,14 +68,14 @@ function* eachTitledPage(contentDir, routesDir) {
55
68
  * an estimated reading time), deriving each page's URL from its directory
56
69
  * relative to `routesDir`. Pure and synchronous so it can be unit-tested.
57
70
  */
58
- export function collectDocs(contentDir, routesDir) {
71
+ export function collectDocs(contentDir, routesDir, versions) {
59
72
  if (!fs.existsSync(contentDir)) {
60
73
  console.warn(`[svelte-docsmith] content directory not found: ${contentDir}\n` +
61
74
  ` The sidebar will be empty. Create your doc pages there, or point docsmith() at the right place with \`content\`.`);
62
75
  return [];
63
76
  }
64
77
  const items = [];
65
- for (const { source, front, url, title, file } of eachTitledPage(contentDir, routesDir)) {
78
+ for (const { source, front, url, title, file, version } of eachTitledPage(contentDir, routesDir, versions)) {
66
79
  items.push({
67
80
  title,
68
81
  path: url,
@@ -70,9 +83,13 @@ export function collectDocs(contentDir, routesDir) {
70
83
  section: readSection(front.section),
71
84
  order: typeof front.order === 'number' ? front.order : undefined,
72
85
  sourcePath: path.relative(process.cwd(), file).split(path.sep).join('/'),
73
- lastUpdated: lastCommitDate(file),
86
+ // Frontmatter wins over git so an archived page keeps the date it last
87
+ // really changed: archiving copies every page in one commit, which would
88
+ // otherwise stamp the whole archive with the day it was created.
89
+ lastUpdated: typeof front.lastUpdated === 'string' ? front.lastUpdated : lastCommitDate(file),
74
90
  readingTime: readingMinutes(extractSearchText(source)),
75
- toc: extractToc(source)
91
+ toc: extractToc(source),
92
+ version
76
93
  });
77
94
  }
78
95
  if (items.length === 0) {
@@ -89,18 +106,19 @@ export function collectDocs(contentDir, routesDir) {
89
106
  * bloating the eagerly-imported nav index. The missing-directory case is
90
107
  * already reported by {@link collectDocs}, so this stays quiet.
91
108
  */
92
- export function collectSearchDocs(contentDir, routesDir) {
109
+ export function collectSearchDocs(contentDir, routesDir, versions) {
93
110
  if (!fs.existsSync(contentDir))
94
111
  return [];
95
112
  const docs = [];
96
- for (const { source, front, url, title } of eachTitledPage(contentDir, routesDir)) {
113
+ for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
97
114
  docs.push({
98
115
  path: url,
99
116
  title,
100
117
  section: sectionLabel(front.section),
101
118
  description: typeof front.description === 'string' ? front.description : undefined,
102
119
  headings: extractToc(source).map((entry) => entry.title),
103
- text: extractSearchText(source)
120
+ text: extractSearchText(source),
121
+ version
104
122
  });
105
123
  }
106
124
  return docs.sort((a, b) => a.path.localeCompare(b.path));
@@ -111,18 +129,19 @@ export function collectSearchDocs(contentDir, routesDir) {
111
129
  * consumed server-side by `llms.txt` / `llms-full.txt` routes, so it never ships
112
130
  * to the client.
113
131
  */
114
- export function collectLlmsDocs(contentDir, routesDir) {
132
+ export function collectLlmsDocs(contentDir, routesDir, versions) {
115
133
  if (!fs.existsSync(contentDir))
116
134
  return [];
117
135
  const docs = [];
118
- for (const { source, front, url, title } of eachTitledPage(contentDir, routesDir)) {
136
+ for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
119
137
  docs.push({
120
138
  path: url,
121
139
  title,
122
140
  section: sectionKey(front.section),
123
141
  order: typeof front.order === 'number' ? front.order : undefined,
124
142
  description: typeof front.description === 'string' ? front.description : undefined,
125
- content: extractLlmsContent(source, title)
143
+ content: extractLlmsContent(source, title),
144
+ version
126
145
  });
127
146
  }
128
147
  return docs.sort((a, b) => a.path.localeCompare(b.path));
@@ -1,4 +1,9 @@
1
1
  import GithubSlugger from 'github-slugger';
2
+ import { proseLines, splitFrontmatter } from '../markdown-source.js';
3
+ /** Drop a page's `<script>`/`<style>` blocks: component setup, not content. */
4
+ function withoutSetupBlocks(body) {
5
+ return body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
6
+ }
2
7
  /** Strip inline markdown so a heading's TOC label is plain text. */
3
8
  export function stripInlineMarkdown(text) {
4
9
  return text
@@ -17,23 +22,9 @@ export function stripInlineMarkdown(text) {
17
22
  * samples are intentionally dropped to keep the index small and prose-focused.
18
23
  */
19
24
  export function extractSearchText(source) {
20
- let body = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
21
- // Component/setup blocks aren't prose; drop them whole before line scanning.
22
- body = body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
25
+ const body = withoutSetupBlocks(splitFrontmatter(source).body);
23
26
  const out = [];
24
- let fence = null;
25
- for (const line of body.split('\n')) {
26
- const f = /^\s*(`{3,}|~{3,})/.exec(line);
27
- if (f) {
28
- const ch = f[1][0];
29
- if (fence === null)
30
- fence = ch;
31
- else if (ch === fence)
32
- fence = null;
33
- continue;
34
- }
35
- if (fence !== null)
36
- continue;
27
+ for (const line of proseLines(body)) {
37
28
  // Skip table delimiter rows (`| --- | :--: |`) — pure structure, no words.
38
29
  if (/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
39
30
  continue;
@@ -63,22 +54,9 @@ export function readingMinutes(text) {
63
54
  * heading ids exactly, not just for common cases.
64
55
  */
65
56
  export function extractToc(source) {
66
- const body = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
67
57
  const slugger = new GithubSlugger();
68
58
  const toc = [];
69
- let fence = null;
70
- for (const line of body.split('\n')) {
71
- const f = /^\s*(`{3,}|~{3,})/.exec(line);
72
- if (f) {
73
- const ch = f[1][0];
74
- if (fence === null)
75
- fence = ch;
76
- else if (ch === fence)
77
- fence = null;
78
- continue;
79
- }
80
- if (fence !== null)
81
- continue;
59
+ for (const line of proseLines(splitFrontmatter(source).body)) {
82
60
  const m = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
83
61
  if (!m)
84
62
  continue;
@@ -95,10 +73,6 @@ export function extractToc(source) {
95
73
  * with the frontmatter title prepended as an `h1` (pages start their body at h2).
96
74
  */
97
75
  export function extractLlmsContent(source, title) {
98
- const body = source
99
- .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
100
- .replace(/<script[\s\S]*?<\/script>/gi, '')
101
- .replace(/<style[\s\S]*?<\/style>/gi, '')
102
- .trim();
76
+ const body = withoutSetupBlocks(splitFrontmatter(source).body).trim();
103
77
  return `# ${title}\n\n${body}`;
104
78
  }
@@ -1,16 +1,17 @@
1
1
  import yaml from 'js-yaml';
2
+ import { splitFrontmatter } from '../markdown-source.js';
2
3
  /**
3
4
  * Parse a page's leading YAML frontmatter block into a plain object. Returns an
4
5
  * empty object when there is no frontmatter; throws a located error on invalid
5
6
  * YAML so a typo surfaces with its filename instead of a blank page.
6
7
  */
7
8
  export function parseFrontmatter(source, file) {
8
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
9
- if (!match)
9
+ const { frontmatter } = splitFrontmatter(source);
10
+ if (frontmatter === undefined)
10
11
  return {};
11
12
  let data;
12
13
  try {
13
- data = yaml.load(match[1]);
14
+ data = yaml.load(frontmatter);
14
15
  }
15
16
  catch (err) {
16
17
  const reason = err instanceof Error ? err.message : String(err);
@@ -1,4 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
+ import { type DocsVersions } from '../../core/version.js';
2
3
  export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
3
4
  export { collectReleases } from './releases.js';
4
5
  export interface DocsmithViteOptions {
@@ -26,6 +27,14 @@ export interface DocsmithViteOptions {
26
27
  * hand-written per-release pages. Default: `'/changelog'`.
27
28
  */
28
29
  changelogPath?: string;
30
+ /**
31
+ * Declare documentation versions to enable versioned docs. `current` is the
32
+ * docs for the latest release: its pages sit directly under `<content>` and
33
+ * keep their unprefixed URLs. Each archived version's pages live under
34
+ * `<content>/<id>/` and are served at `/docs/<id>/…`. Omit for a single,
35
+ * unversioned docs tree (the default).
36
+ */
37
+ versions?: DocsVersions;
29
38
  }
30
39
  /**
31
40
  * The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
@@ -19,6 +19,9 @@
19
19
  import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import { DEFAULT_THEMES, lazyHighlighter } from '../highlight.js';
22
+ import { checkVersions, resolveVersions } from '../../core/version.js';
23
+ import { ARCHIVE_MARKER, discoverArchives } from '../archives.js';
24
+ import { docsBaseFrom } from '../paths.js';
22
25
  import { isPageFile, listPageFiles } from './pages.js';
23
26
  import { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
24
27
  import { collectReleases } from './releases.js';
@@ -47,6 +50,8 @@ const VIRTUAL_CHANGELOG_ID = '\0svelte-docsmith:changelog';
47
50
  function contentIndexPlugin(options) {
48
51
  const contentDir = path.resolve(options.content ?? 'src/routes/docs');
49
52
  const routesDir = path.resolve(options.routes ?? 'src/routes');
53
+ const versions = options.versions;
54
+ const docsBase = docsBaseFrom(routesDir, contentDir);
50
55
  const changelogFile = options.changelog === false ? undefined : path.resolve(options.changelog ?? 'CHANGELOG.md');
51
56
  const changelogRoute = options.changelogPath ?? '/changelog';
52
57
  const changelogOverrides = path.join(routesDir, changelogRoute.replace(/^\//, ''));
@@ -71,19 +76,26 @@ function contentIndexPlugin(options) {
71
76
  if (id === VIRTUAL_CONTENT_ID) {
72
77
  for (const file of listPageFiles(contentDir))
73
78
  this.addWatchFile(file);
74
- const docs = collectDocs(contentDir, routesDir);
75
- return `export const docs = ${JSON.stringify(docs, null, 2)};\n`;
79
+ // Before anything is collected: a config that disagrees with the docs
80
+ // root produces an index that is wrong rather than empty, so it has to
81
+ // fail here rather than render. Re-runs on invalidation, so archiving
82
+ // during `dev` reports immediately.
83
+ checkVersions(versions, discoverArchives(contentDir), ARCHIVE_MARKER);
84
+ const docs = collectDocs(contentDir, routesDir, versions);
85
+ const resolved = resolveVersions(versions, docsBase, docs);
86
+ return (`export const docs = ${JSON.stringify(docs, null, 2)};\n` +
87
+ `export const versions = ${JSON.stringify(resolved, null, 2)};\n`);
76
88
  }
77
89
  if (id === VIRTUAL_SEARCH_ID) {
78
90
  for (const file of listPageFiles(contentDir))
79
91
  this.addWatchFile(file);
80
- const docs = collectSearchDocs(contentDir, routesDir);
92
+ const docs = collectSearchDocs(contentDir, routesDir, versions);
81
93
  return `export const docs = ${JSON.stringify(docs)};\n`;
82
94
  }
83
95
  if (id === VIRTUAL_LLMS_ID) {
84
96
  for (const file of listPageFiles(contentDir))
85
97
  this.addWatchFile(file);
86
- const docs = collectLlmsDocs(contentDir, routesDir);
98
+ const docs = collectLlmsDocs(contentDir, routesDir, versions);
87
99
  return `export const docs = ${JSON.stringify(docs)};\n`;
88
100
  }
89
101
  if (id === VIRTUAL_CHANGELOG_ID) {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { marked } from 'marked';
4
4
  import { parseChangelog } from '../changelog.js';
5
- import { changelogDates } from './git.js';
5
+ import { changelogDates } from '../git.js';
6
6
  /**
7
7
  * Render an entry's markdown to HTML at build time. Changesets entries use
8
8
  * inline code, emphasis, links and nested lists, so shipping the raw markdown
@@ -3,12 +3,17 @@
3
3
 
4
4
  const { release }: { release: ChangelogRelease } = $props();
5
5
 
6
+ // `timeZone: 'UTC'` is not cosmetic. `release.date` is a calendar day, which
7
+ // parses to UTC midnight, so formatting in the ambient zone shifts it a day
8
+ // west of UTC — and shifts it on the client but not on the server, so the
9
+ // rendered date changes under hydration.
6
10
  const formatted = $derived(
7
11
  release.date
8
12
  ? new Date(release.date).toLocaleDateString('en-US', {
9
13
  year: 'numeric',
10
14
  month: 'long',
11
- day: 'numeric'
15
+ day: 'numeric',
16
+ timeZone: 'UTC'
12
17
  })
13
18
  : undefined
14
19
  );
@@ -16,7 +16,7 @@
16
16
  } = $props();
17
17
  </script>
18
18
 
19
- <Button {onclick} variant="ghost" size="icon" class="size-8 {className ?? ''}">
19
+ <Button {onclick} variant="ghost" size="icon" aria-label="Copy" class="size-8 {className ?? ''}">
20
20
  {#if copied}
21
21
  <div in:fade={{ duration: 80 }}>
22
22
  <Check class="text-emerald-500" />
@@ -26,5 +26,6 @@
26
26
  <Copy />
27
27
  </div>
28
28
  {/if}
29
- <span class="sr-only">Copy</span>
29
+ <!-- Announce success to screen readers without altering the button's name. -->
30
+ <span class="sr-only" aria-live="polite">{copied ? 'Copied to clipboard' : ''}</span>
30
31
  </Button>
@@ -2,6 +2,7 @@
2
2
  import { goto } from '$app/navigation';
3
3
  import * as Command from '../shadcn/command/index.js';
4
4
  import { useSearch } from '../../search/context.svelte.js';
5
+ import { useDocsPage } from '../docs-page-context.js';
5
6
  import type { SearchDoc } from '../../core/index.js';
6
7
  import type { SearchEngine } from '../../search/create-search.js';
7
8
  import CornerDownLeft from '@lucide/svelte/icons/corner-down-left';
@@ -17,30 +18,44 @@
17
18
  * Lazily provide the generated search records. Wired by the consumer so the
18
19
  * index is code-split and only fetched when the palette first opens, e.g.
19
20
  * `() => import('svelte-docsmith/search').then((m) => m.docs)`.
21
+ *
22
+ * Receives the active version id on a versioned site, so a loader may
23
+ * return just that version's records; results are scoped either way.
20
24
  */
21
- load: () => Promise<SearchDoc[]>;
25
+ load: (versionId?: string) => Promise<SearchDoc[]>;
22
26
  placeholder?: string;
23
27
  } = $props();
24
28
 
25
29
  const search = useSearch();
30
+ // The version being read, straight off the shell's resolved page view, so
31
+ // results and the cached engine follow the reader across a version switch.
32
+ const docsPage = useDocsPage();
33
+ const activeVersion = $derived(docsPage.view.activeVersionId);
26
34
 
27
35
  let query = $state('');
28
36
  let engine = $state<SearchEngine | null>(null);
29
37
  let status = $state<'idle' | 'loading' | 'error'>('idle');
38
+ // The version the cached engine was built for, so a loader that returns only
39
+ // one version's records is rebuilt when the reader switches versions. Stays
40
+ // constant (and the engine cached) on unversioned and single-version sites.
41
+ let engineVersion: string | undefined;
30
42
 
31
43
  const trimmed = $derived(query.trim());
32
- const results = $derived(engine && trimmed ? engine.search(query) : []);
44
+ // Scope to the active version on a versioned site; `undefined` otherwise.
45
+ const results = $derived(engine && trimmed ? engine.search(query, undefined, activeVersion) : []);
33
46
 
34
47
  // Build the index the first time the palette opens; keep it for later opens.
35
48
  async function ensureEngine() {
36
- if (engine || status === 'loading') return;
49
+ const version = activeVersion;
50
+ if ((engine && engineVersion === version) || status === 'loading') return;
37
51
  status = 'loading';
38
52
  try {
39
53
  const [{ createSearchEngine }, docs] = await Promise.all([
40
54
  import('../../search/create-search.js'),
41
- load()
55
+ load(version)
42
56
  ]);
43
57
  engine = createSearchEngine(docs);
58
+ engineVersion = version;
44
59
  status = 'idle';
45
60
  } catch (error) {
46
61
  console.error('[svelte-docsmith] failed to load the search index', error);
@@ -4,8 +4,11 @@ type $$ComponentProps = {
4
4
  * Lazily provide the generated search records. Wired by the consumer so the
5
5
  * index is code-split and only fetched when the palette first opens, e.g.
6
6
  * `() => import('svelte-docsmith/search').then((m) => m.docs)`.
7
+ *
8
+ * Receives the active version id on a versioned site, so a loader may
9
+ * return just that version's records; results are scoped either way.
7
10
  */
8
- load: () => Promise<SearchDoc[]>;
11
+ load: (versionId?: string) => Promise<SearchDoc[]>;
9
12
  placeholder?: string;
10
13
  };
11
14
  declare const Search: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
3
+ import ArrowRight from '@lucide/svelte/icons/arrow-right';
4
+ import type { ResolvedVersion } from '../../core/index.js';
5
+
6
+ // Rendered by DocsShell only on an archived version, to warn a reader who
7
+ // landed there (usually from a search engine) and give them a one-click path
8
+ // to the current equivalent. A real <a> (not client nav) so it works without
9
+ // JS and stays crawlable.
10
+ const {
11
+ active,
12
+ current,
13
+ href
14
+ }: {
15
+ active: ResolvedVersion;
16
+ current: ResolvedVersion;
17
+ /** This page under the current version, from the resolved page view. */
18
+ href: string;
19
+ } = $props();
20
+ </script>
21
+
22
+ <div class="docsmith-version-banner" role="note">
23
+ <TriangleAlert class="banner-icon" size={18} aria-hidden="true" />
24
+ <p>
25
+ You're reading the <strong>{active.label}</strong> docs. The current version is
26
+ <strong>{current.label}</strong>.
27
+ <a {href}>View this page in {current.label} <ArrowRight size={14} /></a>
28
+ </p>
29
+ </div>
30
+
31
+ <style>
32
+ .docsmith-version-banner {
33
+ display: flex;
34
+ gap: 0.6rem;
35
+ align-items: flex-start;
36
+ margin-bottom: 1.75rem;
37
+ padding: 0.75rem 1rem;
38
+ border: 1px solid;
39
+ border-radius: var(--radius);
40
+ font-size: 0.9rem;
41
+ line-height: 1.5;
42
+ color: var(--foreground);
43
+ border-color: oklch(0.62 0.14 75 / 0.3);
44
+ background: oklch(0.62 0.14 75 / 0.1);
45
+ }
46
+ :global(.docsmith-version-banner .banner-icon) {
47
+ margin-top: 0.1rem;
48
+ flex-shrink: 0;
49
+ color: oklch(0.62 0.14 75);
50
+ }
51
+ :global(.dark) .docsmith-version-banner :global(.banner-icon) {
52
+ color: oklch(0.82 0.13 80);
53
+ }
54
+ .docsmith-version-banner p {
55
+ margin: 0;
56
+ }
57
+ .docsmith-version-banner a {
58
+ display: inline-flex;
59
+ align-items: center;
60
+ gap: 0.2rem;
61
+ font-weight: 600;
62
+ color: var(--primary);
63
+ white-space: nowrap;
64
+ text-decoration: underline;
65
+ text-underline-offset: 2px;
66
+ }
67
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { ResolvedVersion } from '../../core/index.js';
2
+ type $$ComponentProps = {
3
+ active: ResolvedVersion;
4
+ current: ResolvedVersion;
5
+ /** This page under the current version, from the resolved page view. */
6
+ href: string;
7
+ };
8
+ declare const VersionBanner: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type VersionBanner = ReturnType<typeof VersionBanner>;
10
+ export default VersionBanner;
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import { goto } from '$app/navigation';
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 ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
7
+ import type { VersionLink } from '../../core/index.js';
8
+
9
+ // Purely presentational: where each version leads is resolved once, with the
10
+ // rest of the page view, in `core/docs-page.ts`.
11
+ const {
12
+ links
13
+ }: {
14
+ /** Every declared version and its destination, in switcher order. */
15
+ links: VersionLink[];
16
+ } = $props();
17
+
18
+ const active = $derived(links.find((link) => link.active));
19
+
20
+ function select(link: VersionLink) {
21
+ if (link.active) return;
22
+ goto(link.href);
23
+ }
24
+ </script>
25
+
26
+ <!-- Nothing to switch from without an active version, which only happens on an
27
+ unversioned site, where the header doesn't render this at all. -->
28
+ {#if active}
29
+ <DropdownMenu.Root>
30
+ <DropdownMenu.Trigger>
31
+ {#snippet child({ props })}
32
+ <Button
33
+ variant="outline"
34
+ size="sm"
35
+ class="h-8 gap-1.5 px-2.5 font-medium"
36
+ aria-label={`Documentation version: ${active.label}. Change version`}
37
+ {...props}
38
+ >
39
+ <span class="truncate">{active.label}</span>
40
+ <ChevronsUpDown class="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
41
+ </Button>
42
+ {/snippet}
43
+ </DropdownMenu.Trigger>
44
+ <DropdownMenu.Content align="start" class="w-44">
45
+ {#each links as link (link.id)}
46
+ <DropdownMenu.Item onSelect={() => select(link)} class="gap-2">
47
+ <Check class="size-4 {link.active ? 'opacity-100' : 'opacity-0'}" aria-hidden="true" />
48
+ <span class="flex-1 truncate">{link.label}</span>
49
+ </DropdownMenu.Item>
50
+ {/each}
51
+ </DropdownMenu.Content>
52
+ </DropdownMenu.Root>
53
+ {/if}
@@ -0,0 +1,8 @@
1
+ import type { VersionLink } from '../../core/index.js';
2
+ type $$ComponentProps = {
3
+ /** Every declared version and its destination, in switcher order. */
4
+ links: VersionLink[];
5
+ };
6
+ declare const VersionSwitcher: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type VersionSwitcher = ReturnType<typeof VersionSwitcher>;
8
+ export default VersionSwitcher;
@@ -0,0 +1,20 @@
1
+ import type { DocsPageView } from '../core/index.js';
2
+ /** A live read of the page view: `view` re-reads the shell's `$derived`. */
3
+ export type DocsPageContext = {
4
+ readonly view: DocsPageView;
5
+ };
6
+ /**
7
+ * Publish the page view. `DocsShell` calls this once, with a getter rather than
8
+ * a value, so readers see the current `$derived` rather than the one that
9
+ * existed at init.
10
+ */
11
+ export declare function setDocsPage(view: () => DocsPageView): void;
12
+ /**
13
+ * Read the page view. `getContext` only works during init, so this hands back
14
+ * an accessor rather than a snapshot: hold it at init, read `.view` where the
15
+ * value is used, and the read stays reactive.
16
+ *
17
+ * Throws outside `DocsShell`. Every caller is internal chrome the shell renders
18
+ * itself, so an absent context is a wiring bug, not a supported mode.
19
+ */
20
+ export declare function useDocsPage(): DocsPageContext;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The resolved page view, on context.
3
+ *
4
+ * `core/docs-page.ts` decides what the page is; this is the single route that
5
+ * decision travels to the chrome. Publishing it once is what keeps the headers
6
+ * from taking props described in terms of their grandchildren, and the search
7
+ * scope from being pushed into shared mutable state by an effect.
8
+ *
9
+ * Sits at the root of `components/` rather than in a concern folder of its own:
10
+ * `core/` stays free of any Svelte import, and both `layouts/` and `chrome/`
11
+ * read this, so it can't live in either without one importing the other.
12
+ */
13
+ import { getContext, setContext } from 'svelte';
14
+ const KEY = Symbol('docsmith-page');
15
+ /**
16
+ * Publish the page view. `DocsShell` calls this once, with a getter rather than
17
+ * a value, so readers see the current `$derived` rather than the one that
18
+ * existed at init.
19
+ */
20
+ export function setDocsPage(view) {
21
+ setContext(KEY, view);
22
+ }
23
+ /**
24
+ * Read the page view. `getContext` only works during init, so this hands back
25
+ * an accessor rather than a snapshot: hold it at init, read `.view` where the
26
+ * value is used, and the read stays reactive.
27
+ *
28
+ * Throws outside `DocsShell`. Every caller is internal chrome the shell renders
29
+ * itself, so an absent context is a wiring bug, not a supported mode.
30
+ */
31
+ export function useDocsPage() {
32
+ const get = getContext(KEY);
33
+ if (!get) {
34
+ throw new Error('[svelte-docsmith] this component must be rendered inside <DocsShell>.');
35
+ }
36
+ return {
37
+ get view() {
38
+ return get();
39
+ }
40
+ };
41
+ }
@@ -6,12 +6,13 @@
6
6
  import SearchTrigger from '../chrome/search-trigger.svelte';
7
7
  import { useSearch } from '../../search/context.svelte.js';
8
8
  import ThemeToggle from '../chrome/theme-toggle.svelte';
9
+ import VersionSwitcher from '../chrome/version-switcher.svelte';
9
10
  import type { DocsmithConfig, DocsmithLink } from '../../core/index.js';
10
11
  import BookOpenText from '@lucide/svelte/icons/book-open-text';
11
12
  import type { Snippet } from 'svelte';
12
- import { page } from '$app/state';
13
13
  import { cn } from '../../utils/cn.js';
14
14
  import { normalizePath } from '../../utils/normalize-path.js';
15
+ import { useDocsPage } from '../docs-page-context.js';
15
16
 
16
17
  const {
17
18
  config,
@@ -28,7 +29,10 @@
28
29
  // Present only when the consumer passed a `search` loader to DocsShell.
29
30
  const search = useSearch();
30
31
 
31
- const current = $derived(normalizePath(page.url.pathname));
32
+ // Everything about the page being read comes off the shell's one resolved
33
+ // view, including the normalized path the nav highlights against.
34
+ const docsPage = useDocsPage();
35
+ const current = $derived(docsPage.view.pathname);
32
36
 
33
37
  /**
34
38
  * A header link is usually the entry point to a whole section rather than a
@@ -91,7 +95,11 @@
91
95
  </nav>
92
96
  {/if}
93
97
 
94
- {#if config.version}
98
+ {#if docsPage.view.versionLinks.length > 1}
99
+ <div class="px-1">
100
+ <VersionSwitcher links={docsPage.view.versionLinks} />
101
+ </div>
102
+ {:else if config.version}
95
103
  <div class="px-2">
96
104
  <Badge
97
105
  variant="outline"
@@ -121,6 +129,7 @@
121
129
  size="icon"
122
130
  variant="ghost"
123
131
  target="_blank"
132
+ rel="noopener noreferrer"
124
133
  href={config.github}
125
134
  class="text-muted-foreground hover:text-foreground size-8"
126
135
  >