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.
- package/bin/svelte-docsmith.mjs +16 -0
- package/dist/buildtime/archive.d.ts +31 -0
- package/dist/buildtime/archive.js +50 -0
- package/dist/buildtime/archives.d.ts +18 -0
- package/dist/buildtime/archives.js +43 -0
- package/dist/buildtime/cli/archive-version.d.ts +18 -0
- package/dist/buildtime/cli/archive-version.js +104 -0
- package/dist/buildtime/cli/error.d.ts +9 -0
- package/dist/buildtime/cli/error.js +9 -0
- package/dist/buildtime/cli/run.d.ts +13 -0
- package/dist/buildtime/cli/run.js +62 -0
- package/dist/buildtime/{vite/git.d.ts → git.d.ts} +1 -1
- package/dist/buildtime/{vite/git.js → git.js} +15 -3
- package/dist/buildtime/markdown-source.d.ts +31 -0
- package/dist/buildtime/markdown-source.js +90 -0
- package/dist/buildtime/paths.d.ts +7 -0
- package/dist/buildtime/paths.js +15 -0
- package/dist/buildtime/vite/collect.d.ts +4 -3
- package/dist/buildtime/vite/collect.js +32 -13
- package/dist/buildtime/vite/extract.js +9 -35
- package/dist/buildtime/vite/frontmatter.js +4 -3
- package/dist/buildtime/vite/index.d.ts +9 -0
- package/dist/buildtime/vite/index.js +16 -4
- package/dist/buildtime/vite/releases.js +1 -1
- package/dist/components/changelog/changelog-entry.svelte +6 -1
- package/dist/components/chrome/copy-button.svelte +3 -2
- package/dist/components/chrome/search.svelte +19 -4
- package/dist/components/chrome/search.svelte.d.ts +4 -1
- package/dist/components/chrome/version-banner.svelte +67 -0
- package/dist/components/chrome/version-banner.svelte.d.ts +10 -0
- package/dist/components/chrome/version-switcher.svelte +53 -0
- package/dist/components/chrome/version-switcher.svelte.d.ts +8 -0
- package/dist/components/docs-page-context.d.ts +20 -0
- package/dist/components/docs-page-context.js +41 -0
- package/dist/components/layouts/docs-header.svelte +12 -3
- package/dist/components/layouts/docs-mobile-header.svelte +8 -0
- package/dist/components/layouts/docs-shell.svelte +81 -62
- package/dist/components/layouts/docs-shell.svelte.d.ts +11 -2
- package/dist/components/layouts/error-page.svelte +23 -3
- package/dist/components/layouts/error-page.svelte.d.ts +8 -2
- package/dist/components/layouts/seo-head.svelte +8 -1
- package/dist/components/layouts/seo-head.svelte.d.ts +2 -0
- package/dist/core/changelog.d.ts +4 -1
- package/dist/core/content.d.ts +15 -1
- package/dist/core/docs-page.d.ts +85 -0
- package/dist/core/docs-page.js +72 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/version.d.ts +116 -0
- package/dist/core/version.js +186 -0
- package/dist/fallbacks/content.d.ts +2 -0
- package/dist/fallbacks/content.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/search/create-search.d.ts +8 -2
- package/dist/search/create-search.js +11 -3
- package/dist/theme.css +21 -0
- 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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
if (
|
|
9
|
+
const { frontmatter } = splitFrontmatter(source);
|
|
10
|
+
if (frontmatter === undefined)
|
|
10
11
|
return {};
|
|
11
12
|
let data;
|
|
12
13
|
try {
|
|
13
|
-
data = yaml.load(
|
|
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
|
-
|
|
75
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
>
|