svelte-docsmith 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/svelte-docsmith.mjs +161 -0
  2. package/dist/buildtime/archive.d.ts +31 -0
  3. package/dist/buildtime/archive.js +50 -0
  4. package/dist/buildtime/markdown-source.d.ts +31 -0
  5. package/dist/buildtime/markdown-source.js +90 -0
  6. package/dist/buildtime/vite/collect.d.ts +4 -3
  7. package/dist/buildtime/vite/collect.js +31 -12
  8. package/dist/buildtime/vite/extract.js +9 -35
  9. package/dist/buildtime/vite/frontmatter.js +4 -3
  10. package/dist/buildtime/vite/index.d.ts +9 -0
  11. package/dist/buildtime/vite/index.js +11 -4
  12. package/dist/components/chrome/copy-button.svelte +3 -2
  13. package/dist/components/chrome/search.svelte +16 -4
  14. package/dist/components/chrome/search.svelte.d.ts +4 -1
  15. package/dist/components/chrome/version-banner.svelte +82 -0
  16. package/dist/components/chrome/version-banner.svelte.d.ts +10 -0
  17. package/dist/components/chrome/version-switcher.svelte +63 -0
  18. package/dist/components/chrome/version-switcher.svelte.d.ts +14 -0
  19. package/dist/components/layouts/docs-header.svelte +26 -3
  20. package/dist/components/layouts/docs-header.svelte.d.ts +9 -1
  21. package/dist/components/layouts/docs-mobile-header.svelte +25 -2
  22. package/dist/components/layouts/docs-mobile-header.svelte.d.ts +9 -1
  23. package/dist/components/layouts/docs-shell.svelte +84 -66
  24. package/dist/components/layouts/docs-shell.svelte.d.ts +11 -2
  25. package/dist/components/layouts/error-page.svelte +23 -3
  26. package/dist/components/layouts/error-page.svelte.d.ts +8 -2
  27. package/dist/components/layouts/seo-head.svelte +8 -1
  28. package/dist/components/layouts/seo-head.svelte.d.ts +2 -0
  29. package/dist/core/content.d.ts +9 -0
  30. package/dist/core/docs-page.d.ts +70 -0
  31. package/dist/core/docs-page.js +55 -0
  32. package/dist/core/index.d.ts +2 -0
  33. package/dist/core/index.js +2 -0
  34. package/dist/core/version.d.ts +80 -0
  35. package/dist/core/version.js +85 -0
  36. package/dist/fallbacks/content.d.ts +2 -0
  37. package/dist/fallbacks/content.js +1 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +5 -0
  40. package/dist/search/context.svelte.d.ts +2 -0
  41. package/dist/search/context.svelte.js +2 -0
  42. package/dist/search/create-search.d.ts +8 -2
  43. package/dist/search/create-search.js +11 -3
  44. package/dist/theme.css +21 -0
  45. package/package.json +7 -2
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // The svelte-docsmith maintenance CLI. Currently one command: `archive-version`,
3
+ // which freezes today's docs into an archived version folder so they stay live
4
+ // after a breaking release.
5
+ //
6
+ // The current version is served unprefixed from the docs root and is the folder
7
+ // you keep editing; archives are copies under their own prefix. See
8
+ // docs/adr/0001-unprefixed-current-docs.md. The text transforms live in
9
+ // `src/lib/buildtime/archive.ts` so they are typechecked and unit-tested; this
10
+ // file is the filesystem wrapper around them.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { execFileSync } from 'node:child_process';
14
+ import {
15
+ rewriteDocsLinks,
16
+ freezeLastUpdated,
17
+ isInheritedRouteFile
18
+ } from '../dist/buildtime/archive.js';
19
+
20
+ const HELP = `svelte-docsmith — docs maintenance CLI
21
+
22
+ Usage:
23
+ svelte-docsmith archive-version <id> [options]
24
+
25
+ Freeze the current docs into an archived version folder. The archive keeps
26
+ serving the release it documents while you go on editing the docs root.
27
+
28
+ Options:
29
+ --label <label> switcher label for the archive (default: the id)
30
+ --content <dir> docs content directory (default: src/routes/docs)
31
+ --routes <dir> SvelteKit routes directory (default: src/routes)
32
+ `;
33
+
34
+ // Written into every archive this command creates, so later runs know which
35
+ // directories are already archives and must not be copied into the new one.
36
+ const MARKER = '.docsmith-archive';
37
+
38
+ const [command, ...rest] = process.argv.slice(2);
39
+
40
+ if (command !== 'archive-version') {
41
+ console.log(HELP);
42
+ process.exit(command ? 1 : 0);
43
+ }
44
+
45
+ const opts = { content: 'src/routes/docs', routes: 'src/routes', label: undefined };
46
+ let id;
47
+ for (let i = 0; i < rest.length; i++) {
48
+ const arg = rest[i];
49
+ if (arg === '--label') opts.label = rest[++i];
50
+ else if (arg === '--content') opts.content = rest[++i];
51
+ else if (arg === '--routes') opts.routes = rest[++i];
52
+ else if (!arg.startsWith('--') && !id) id = arg;
53
+ }
54
+
55
+ const fail = (message) => {
56
+ console.error(`svelte-docsmith archive-version: ${message}`);
57
+ process.exit(1);
58
+ };
59
+
60
+ if (!id) fail('missing <id>. e.g. `svelte-docsmith archive-version v1`');
61
+ // Must start alphanumeric: the id is both a directory name and a URL segment, so
62
+ // `.`, `..` and dotfiles would escape the docs root or make a hidden, dead route.
63
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
64
+ fail(`invalid id: ${id}. Start with a letter or digit; it is used as a URL segment.`);
65
+ }
66
+
67
+ const contentDir = path.resolve(opts.content);
68
+ const routesDir = path.resolve(opts.routes);
69
+ const toDir = path.join(contentDir, id);
70
+ const rel = (p) => path.relative(process.cwd(), p);
71
+
72
+ if (!fs.existsSync(contentDir)) fail(`content directory not found: ${rel(contentDir)}`);
73
+ if (fs.existsSync(toDir)) fail(`target already exists: ${rel(toDir)}`);
74
+
75
+ // The docs URL base (e.g. `/docs`), derived exactly as the vite plugin does.
76
+ const docsBase = '/' + path.relative(routesDir, contentDir).split(path.sep).join('/');
77
+
78
+ /** Directories directly under the docs root that are already archives. */
79
+ const archivedIds = new Set(
80
+ fs
81
+ .readdirSync(contentDir, { withFileTypes: true })
82
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(contentDir, e.name, MARKER)))
83
+ .map((e) => e.name)
84
+ );
85
+
86
+ const isPage = (name) => name.endsWith('+page.md') || name.endsWith('+page.svx');
87
+
88
+ /** Copy the docs root into the archive, skipping what the archive shouldn't hold. */
89
+ function copyCurrent(from, to) {
90
+ fs.mkdirSync(to, { recursive: true });
91
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
92
+ const src = path.join(from, entry.name);
93
+ const dest = path.join(to, entry.name);
94
+ if (entry.isDirectory()) {
95
+ // Never descend into the archive being written (it lives inside the docs
96
+ // root) or into an archive written by an earlier run.
97
+ if (src === toDir) continue;
98
+ if (from === contentDir && archivedIds.has(entry.name)) continue;
99
+ copyCurrent(src, dest);
100
+ } else {
101
+ // The root layout and error page already apply to the archive, which is
102
+ // nested inside them; copying them would nest a second DocsShell.
103
+ if (from === contentDir && isInheritedRouteFile(entry.name)) continue;
104
+ fs.copyFileSync(src, dest);
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Every file in the archive, paired with the source file it was copied from. */
110
+ function* eachCopiedFile(dir = toDir) {
111
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
112
+ const file = path.join(dir, entry.name);
113
+ if (entry.isDirectory()) yield* eachCopiedFile(file);
114
+ else yield { file, source: path.join(contentDir, path.relative(toDir, file)) };
115
+ }
116
+ }
117
+
118
+ /** The date a page last really changed, so the archive doesn't claim today. */
119
+ function lastCommitDate(file) {
120
+ try {
121
+ const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', file], {
122
+ cwd: path.dirname(file),
123
+ encoding: 'utf-8',
124
+ stdio: ['ignore', 'pipe', 'ignore']
125
+ }).trim();
126
+ return out || undefined;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ copyCurrent(contentDir, toDir);
133
+ fs.writeFileSync(
134
+ path.join(toDir, MARKER),
135
+ `Archived docs for ${id}, created by \`svelte-docsmith archive-version\`.\n` +
136
+ `This folder is frozen: edit the docs root instead.\n`
137
+ );
138
+
139
+ let pages = 0;
140
+ for (const { file, source } of eachCopiedFile()) {
141
+ if (!isPage(path.basename(file))) continue;
142
+ pages++;
143
+ const text = fs.readFileSync(file, 'utf-8');
144
+ fs.writeFileSync(
145
+ file,
146
+ freezeLastUpdated(
147
+ rewriteDocsLinks(text, { docsBase, versionId: id, archivedIds }),
148
+ lastCommitDate(source)
149
+ )
150
+ );
151
+ }
152
+
153
+ const label = opts.label ?? id;
154
+ console.log(`\n✓ Archived the current docs into ${rel(toDir)} (${pages} pages)\n`);
155
+ console.log('Links were rewritten to stay inside the archive, and each page kept');
156
+ console.log('its real last-updated date. Review the diff, then update');
157
+ console.log('docsmith({ versions }) so the archive is served:\n');
158
+ console.log(' versions: {');
159
+ console.log(" current: { id: '<new release>', label: '<new release>' },");
160
+ console.log(` archived: [{ id: '${id}', label: '${label}' }${archivedIds.size ? ', …' : ''}]`);
161
+ console.log(' }\n');
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The pure text transforms behind `svelte-docsmith archive-version`. Archiving
3
+ * copies the docs root into a frozen version folder, and a verbatim copy gets
4
+ * two things wrong: its links keep resolving to the current docs, and every page
5
+ * looks like it changed on the day the archive was made. These fix both.
6
+ *
7
+ * Kept here rather than in `bin/` so they are typechecked and unit-tested; the
8
+ * CLI is a thin wrapper over them. See `docs/adr/0002-archives-are-rewritten-source-copies.md`.
9
+ */
10
+ /** Route files at the docs root that an archive nested inside it already inherits. */
11
+ export declare function isInheritedRouteFile(name: string): boolean;
12
+ /**
13
+ * Point a page's docs links at the archive it is being copied into. An absolute
14
+ * link like `](/docs/theming)` resolves to the *current* docs forever, so
15
+ * without this an archive silently walks readers into newer content.
16
+ *
17
+ * Links already targeting a version folder are left alone, as are links inside
18
+ * fenced code, which are sample code rather than navigation.
19
+ */
20
+ export declare function rewriteDocsLinks(text: string, options: {
21
+ docsBase: string;
22
+ versionId: string;
23
+ archivedIds?: Iterable<string>;
24
+ }): string;
25
+ /**
26
+ * Write a page's real last-updated date into its frontmatter. The collector
27
+ * prefers frontmatter over the git date, so an archive keeps the date each page
28
+ * was actually accurate on instead of the day the archive was created. Leaves an
29
+ * existing `lastUpdated` and any page without frontmatter alone.
30
+ */
31
+ export declare function freezeLastUpdated(text: string, date: string | undefined): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The pure text transforms behind `svelte-docsmith archive-version`. Archiving
3
+ * copies the docs root into a frozen version folder, and a verbatim copy gets
4
+ * two things wrong: its links keep resolving to the current docs, and every page
5
+ * looks like it changed on the day the archive was made. These fix both.
6
+ *
7
+ * Kept here rather than in `bin/` so they are typechecked and unit-tested; the
8
+ * CLI is a thin wrapper over them. See `docs/adr/0002-archives-are-rewritten-source-copies.md`.
9
+ */
10
+ import { outsideCodeFences, withFrontmatter } from './markdown-source.js';
11
+ /** Route files at the docs root that an archive nested inside it already inherits. */
12
+ export function isInheritedRouteFile(name) {
13
+ return /^\+(layout|error)\./.test(name);
14
+ }
15
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+ /**
17
+ * Point a page's docs links at the archive it is being copied into. An absolute
18
+ * link like `](/docs/theming)` resolves to the *current* docs forever, so
19
+ * without this an archive silently walks readers into newer content.
20
+ *
21
+ * Links already targeting a version folder are left alone, as are links inside
22
+ * fenced code, which are sample code rather than navigation.
23
+ */
24
+ export function rewriteDocsLinks(text, options) {
25
+ const { docsBase, versionId } = options;
26
+ // Never double-prefix: skip links into an existing archive, and into the one
27
+ // being created (a page may already point at the id we're about to write).
28
+ const skip = new Set([...(options.archivedIds ?? []), versionId]);
29
+ const pattern = new RegExp(`(\\]\\(|href="|href='|\\]:[ \\t]+)(${escapeRe(docsBase)})([^)"'\\s]*)`, 'g');
30
+ return outsideCodeFences(text, (chunk) => chunk.replace(pattern, (match, prefix, base, rest) => {
31
+ // Guard the segment boundary: `/docsmith` must not become `/docs/v1mith`.
32
+ if (rest && !/^[/#?]/.test(rest))
33
+ return match;
34
+ const firstSegment = rest.startsWith('/') ? rest.slice(1).split(/[/#?]/)[0] : '';
35
+ if (firstSegment && skip.has(firstSegment))
36
+ return match;
37
+ return `${prefix}${base}/${versionId}${rest}`;
38
+ }));
39
+ }
40
+ /**
41
+ * Write a page's real last-updated date into its frontmatter. The collector
42
+ * prefers frontmatter over the git date, so an archive keeps the date each page
43
+ * was actually accurate on instead of the day the archive was created. Leaves an
44
+ * existing `lastUpdated` and any page without frontmatter alone.
45
+ */
46
+ export function freezeLastUpdated(text, date) {
47
+ if (!date)
48
+ return text;
49
+ return withFrontmatter(text, (front) => /^lastUpdated:/m.test(front) ? front : `${front}\nlastUpdated: '${date}'`);
50
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The structure of a markdown page, as every build-time pass over one needs to
3
+ * see it: where the frontmatter block ends, and which lines are prose rather
4
+ * than fenced code.
5
+ *
6
+ * Both rules used to be respelled in each pass, and the copies drifted. A fence
7
+ * scanner that toggles on the marker's first character alone closes a ````
8
+ * block on the ```svelte nested inside it, which pulls sample code into the
9
+ * search index and sample headings into the table of contents. Keeping the
10
+ * rules here means the search index, the table of contents, the LLM output and
11
+ * the archive rewriter all agree on what a page's prose is.
12
+ */
13
+ /** A page split at its frontmatter delimiters. */
14
+ export type MarkdownSource = {
15
+ /** The YAML between the `---` lines, or undefined when the page has none. */
16
+ frontmatter: string | undefined;
17
+ /** Everything after the frontmatter block: prose, headings, fenced code, tags. */
18
+ body: string;
19
+ };
20
+ /** Split a page into its frontmatter and its body. */
21
+ export declare function splitFrontmatter(source: string): MarkdownSource;
22
+ /**
23
+ * Rewrite a page's frontmatter YAML in place, leaving the delimiters and the
24
+ * body byte-identical — including their line endings, which a split-and-rejoin
25
+ * would normalise. A page without frontmatter is returned untouched.
26
+ */
27
+ export declare function withFrontmatter(source: string, transform: (frontmatter: string) => string): string;
28
+ /** Apply `transform` to a page's prose lines, leaving fenced code untouched. */
29
+ export declare function outsideCodeFences(text: string, transform: (line: string) => string): string;
30
+ /** A page's prose lines, in order, with fenced code and its markers dropped. */
31
+ export declare function proseLines(text: string): Generator<string>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The structure of a markdown page, as every build-time pass over one needs to
3
+ * see it: where the frontmatter block ends, and which lines are prose rather
4
+ * than fenced code.
5
+ *
6
+ * Both rules used to be respelled in each pass, and the copies drifted. A fence
7
+ * scanner that toggles on the marker's first character alone closes a ````
8
+ * block on the ```svelte nested inside it, which pulls sample code into the
9
+ * search index and sample headings into the table of contents. Keeping the
10
+ * rules here means the search index, the table of contents, the LLM output and
11
+ * the archive rewriter all agree on what a page's prose is.
12
+ */
13
+ /**
14
+ * A page's frontmatter block: `---` on the first line, through the next line
15
+ * that starts with `---`. Group 1 is the YAML; the whole match spans the block
16
+ * including the line break that ends it, so the body starts where it stops.
17
+ *
18
+ * Deliberately as loose about the closing delimiter as remark-frontmatter,
19
+ * which is what mdsvex strips at render time. A stricter rule here would read
20
+ * a page differently from the renderer: a page mdsvex renders body-only would
21
+ * keep its frontmatter as prose, or lose the title that puts it in the nav.
22
+ */
23
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
24
+ /** An opening or closing code fence, indented anywhere on its line. */
25
+ const FENCE = /^\s*(`{3,}|~{3,})/;
26
+ /** Split a page into its frontmatter and its body. */
27
+ export function splitFrontmatter(source) {
28
+ const match = FRONTMATTER.exec(source);
29
+ if (!match)
30
+ return { frontmatter: undefined, body: source };
31
+ return { frontmatter: match[1], body: source.slice(match[0].length) };
32
+ }
33
+ /**
34
+ * Rewrite a page's frontmatter YAML in place, leaving the delimiters and the
35
+ * body byte-identical — including their line endings, which a split-and-rejoin
36
+ * would normalise. A page without frontmatter is returned untouched.
37
+ */
38
+ export function withFrontmatter(source, transform) {
39
+ const match = FRONTMATTER.exec(source);
40
+ if (!match)
41
+ return source;
42
+ const start = match[0].indexOf('\n') + 1;
43
+ return source.slice(0, start) + transform(match[1]) + source.slice(start + match[1].length);
44
+ }
45
+ /**
46
+ * A line-by-line scanner for fence state, rather than one regex pairing fences
47
+ * up, so an indented fence (inside a list item or a component) and a `~~~`
48
+ * fence are both recognised. Per CommonMark a fence closes only on the same
49
+ * character, at least as long as the opener, with no info string — so the
50
+ * ```svelte inside a ```` block opens nothing and closes nothing.
51
+ *
52
+ * Returns a predicate that consumes the page's lines in order and answers
53
+ * whether each one is prose. Fence markers themselves are not.
54
+ */
55
+ function fenceScanner() {
56
+ let open;
57
+ return (line) => {
58
+ const match = FENCE.exec(line);
59
+ if (open) {
60
+ const closes = match &&
61
+ match[1][0] === open[0] &&
62
+ match[1].length >= open.length &&
63
+ !line.slice(match[0].length).trim();
64
+ if (closes)
65
+ open = undefined;
66
+ return false;
67
+ }
68
+ if (match) {
69
+ open = match[1];
70
+ return false;
71
+ }
72
+ return true;
73
+ };
74
+ }
75
+ /** Apply `transform` to a page's prose lines, leaving fenced code untouched. */
76
+ export function outsideCodeFences(text, transform) {
77
+ const isProse = fenceScanner();
78
+ return text
79
+ .split('\n')
80
+ .map((line) => (isProse(line) ? transform(line) : line))
81
+ .join('\n');
82
+ }
83
+ /** A page's prose lines, in order, with fenced code and its markers dropped. */
84
+ export function* proseLines(text) {
85
+ const isProse = fenceScanner();
86
+ for (const line of text.split('\n')) {
87
+ if (isProse(line))
88
+ yield line;
89
+ }
90
+ }
@@ -1,11 +1,12 @@
1
1
  import type { DocsContentItem, LlmsDoc, SearchDoc } from '../../core/content.js';
2
+ import type { DocsVersions } from '../../core/version.js';
2
3
  /**
3
4
  * Scan `contentDir` for `+page.md`/`+page.svx` files and read the frontmatter
4
5
  * fields the sidebar needs (plus the heading list for a server-rendered TOC and
5
6
  * an estimated reading time), deriving each page's URL from its directory
6
7
  * relative to `routesDir`. Pure and synchronous so it can be unit-tested.
7
8
  */
8
- export declare function collectDocs(contentDir: string, routesDir: string): DocsContentItem[];
9
+ export declare function collectDocs(contentDir: string, routesDir: string, versions?: DocsVersions): DocsContentItem[];
9
10
  /**
10
11
  * Build the search records for every page under `contentDir`: title, section,
11
12
  * description, heading list, and plain-text body. Served as the lazy-loaded
@@ -13,11 +14,11 @@ export declare function collectDocs(contentDir: string, routesDir: string): Docs
13
14
  * bloating the eagerly-imported nav index. The missing-directory case is
14
15
  * already reported by {@link collectDocs}, so this stays quiet.
15
16
  */
16
- export declare function collectSearchDocs(contentDir: string, routesDir: string): SearchDoc[];
17
+ export declare function collectSearchDocs(contentDir: string, routesDir: string, versions?: DocsVersions): SearchDoc[];
17
18
  /**
18
19
  * Build the LLM records for every page: title, section, description, and the
19
20
  * full markdown content. Served as the `svelte-docsmith/llms` virtual module and
20
21
  * consumed server-side by `llms.txt` / `llms-full.txt` routes, so it never ships
21
22
  * to the client.
22
23
  */
23
- export declare function collectLlmsDocs(contentDir: string, routesDir: string): LlmsDoc[];
24
+ export declare function collectLlmsDocs(contentDir: string, routesDir: string, versions?: DocsVersions): LlmsDoc[];
@@ -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,7 @@
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 { resolveVersions } from '../../core/version.js';
22
23
  import { isPageFile, listPageFiles } from './pages.js';
23
24
  import { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
24
25
  import { collectReleases } from './releases.js';
@@ -47,6 +48,10 @@ const VIRTUAL_CHANGELOG_ID = '\0svelte-docsmith:changelog';
47
48
  function contentIndexPlugin(options) {
48
49
  const contentDir = path.resolve(options.content ?? 'src/routes/docs');
49
50
  const routesDir = path.resolve(options.routes ?? 'src/routes');
51
+ const versions = options.versions;
52
+ // The docs URL base, e.g. `/docs`, derived from the content dir's location
53
+ // under the routes dir — the same mapping `collect.ts` uses for page URLs.
54
+ const docsBase = '/' + path.relative(routesDir, contentDir).split(path.sep).join('/');
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,21 @@ 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
+ const docs = collectDocs(contentDir, routesDir, versions);
80
+ const resolved = resolveVersions(versions, docsBase, docs);
81
+ return (`export const docs = ${JSON.stringify(docs, null, 2)};\n` +
82
+ `export const versions = ${JSON.stringify(resolved, null, 2)};\n`);
76
83
  }
77
84
  if (id === VIRTUAL_SEARCH_ID) {
78
85
  for (const file of listPageFiles(contentDir))
79
86
  this.addWatchFile(file);
80
- const docs = collectSearchDocs(contentDir, routesDir);
87
+ const docs = collectSearchDocs(contentDir, routesDir, versions);
81
88
  return `export const docs = ${JSON.stringify(docs)};\n`;
82
89
  }
83
90
  if (id === VIRTUAL_LLMS_ID) {
84
91
  for (const file of listPageFiles(contentDir))
85
92
  this.addWatchFile(file);
86
- const docs = collectLlmsDocs(contentDir, routesDir);
93
+ const docs = collectLlmsDocs(contentDir, routesDir, versions);
87
94
  return `export const docs = ${JSON.stringify(docs)};\n`;
88
95
  }
89
96
  if (id === VIRTUAL_CHANGELOG_ID) {