svelte-docsmith 0.10.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 (39) hide show
  1. package/bin/svelte-docsmith.mjs +13 -158
  2. package/dist/buildtime/archives.d.ts +18 -0
  3. package/dist/buildtime/archives.js +43 -0
  4. package/dist/buildtime/cli/archive-version.d.ts +18 -0
  5. package/dist/buildtime/cli/archive-version.js +104 -0
  6. package/dist/buildtime/cli/error.d.ts +9 -0
  7. package/dist/buildtime/cli/error.js +9 -0
  8. package/dist/buildtime/cli/run.d.ts +13 -0
  9. package/dist/buildtime/cli/run.js +62 -0
  10. package/dist/buildtime/{vite/git.d.ts → git.d.ts} +1 -1
  11. package/dist/buildtime/{vite/git.js → git.js} +15 -3
  12. package/dist/buildtime/paths.d.ts +7 -0
  13. package/dist/buildtime/paths.js +15 -0
  14. package/dist/buildtime/vite/collect.js +1 -1
  15. package/dist/buildtime/vite/index.js +9 -4
  16. package/dist/buildtime/vite/releases.js +1 -1
  17. package/dist/components/changelog/changelog-entry.svelte +6 -1
  18. package/dist/components/chrome/search.svelte +8 -5
  19. package/dist/components/chrome/version-banner.svelte +5 -20
  20. package/dist/components/chrome/version-banner.svelte.d.ts +3 -3
  21. package/dist/components/chrome/version-switcher.svelte +39 -49
  22. package/dist/components/chrome/version-switcher.svelte.d.ts +3 -9
  23. package/dist/components/docs-page-context.d.ts +20 -0
  24. package/dist/components/docs-page-context.js +41 -0
  25. package/dist/components/layouts/docs-header.svelte +9 -23
  26. package/dist/components/layouts/docs-header.svelte.d.ts +1 -9
  27. package/dist/components/layouts/docs-mobile-header.svelte +6 -21
  28. package/dist/components/layouts/docs-mobile-header.svelte.d.ts +1 -9
  29. package/dist/components/layouts/docs-shell.svelte +29 -28
  30. package/dist/core/changelog.d.ts +4 -1
  31. package/dist/core/content.d.ts +6 -1
  32. package/dist/core/docs-page.d.ts +15 -0
  33. package/dist/core/docs-page.js +20 -3
  34. package/dist/core/index.d.ts +1 -1
  35. package/dist/core/version.d.ts +36 -0
  36. package/dist/core/version.js +101 -0
  37. package/dist/search/context.svelte.d.ts +0 -2
  38. package/dist/search/context.svelte.js +0 -2
  39. package/package.json +1 -1
@@ -1,161 +1,16 @@
1
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)) };
2
+ // Process boundary only. The command itself lives in
3
+ // `src/lib/buildtime/cli/`, where it is typechecked and unit-tested; this file
4
+ // exists so `svelte-docsmith` is a runnable bin entry.
5
+ import { run, CliError } from '../dist/buildtime/cli/run.js';
6
+
7
+ try {
8
+ run(process.argv.slice(2));
9
+ } catch (error) {
10
+ if (error instanceof CliError) {
11
+ // A mistake the user can fix. The message says how; a stack would not help.
12
+ console.error(`svelte-docsmith: ${error.message}`);
13
+ process.exit(1);
115
14
  }
15
+ throw error;
116
16
  }
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,18 @@
1
+ import type { ArchivesOnDisk } from '../core/version.js';
2
+ /**
3
+ * Written into every archive `archive-version` creates. Not a convenience for
4
+ * the command: this file is what makes a directory an archived version rather
5
+ * than an ordinary section of the current docs, and the build reads it. The two
6
+ * are otherwise indistinguishable, since a page is assigned to a version by its
7
+ * first directory segment. See
8
+ * `docs/adr/0003-the-archive-marker-defines-an-archive.md`.
9
+ */
10
+ export declare const ARCHIVE_MARKER = ".docsmith-archive";
11
+ /** The text written into a new archive's marker file. */
12
+ export declare function markerContents(id: string): string;
13
+ /**
14
+ * The directories directly under `contentDir`, and which of them are archives.
15
+ * An absent docs root reads as empty rather than throwing: the collectors
16
+ * already report that case, and the command has its own message for it.
17
+ */
18
+ export declare function discoverArchives(contentDir: string): ArchivesOnDisk;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What the docs root says about itself: which directories under it are archived
3
+ * versions. The scan is deliberately thin, so the reconciliation it feeds
4
+ * (`checkVersions`) stays pure and testable without a filesystem.
5
+ *
6
+ * Both callers cross this seam. The vite plugin scans before resolving versions;
7
+ * `archive-version` scans so it knows which directories not to copy into the
8
+ * archive it is writing.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ /**
13
+ * Written into every archive `archive-version` creates. Not a convenience for
14
+ * the command: this file is what makes a directory an archived version rather
15
+ * than an ordinary section of the current docs, and the build reads it. The two
16
+ * are otherwise indistinguishable, since a page is assigned to a version by its
17
+ * first directory segment. See
18
+ * `docs/adr/0003-the-archive-marker-defines-an-archive.md`.
19
+ */
20
+ export const ARCHIVE_MARKER = '.docsmith-archive';
21
+ /** The text written into a new archive's marker file. */
22
+ export function markerContents(id) {
23
+ return (`Archived docs for ${id}, created by \`svelte-docsmith archive-version\`.\n` +
24
+ `This folder is frozen: edit the docs root instead.\n` +
25
+ `Removing this file makes the build treat it as current-version content.\n`);
26
+ }
27
+ /**
28
+ * The directories directly under `contentDir`, and which of them are archives.
29
+ * An absent docs root reads as empty rather than throwing: the collectors
30
+ * already report that case, and the command has its own message for it.
31
+ */
32
+ export function discoverArchives(contentDir) {
33
+ if (!fs.existsSync(contentDir))
34
+ return { marked: [], directories: [] };
35
+ const directories = fs
36
+ .readdirSync(contentDir, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory())
38
+ .map((entry) => entry.name);
39
+ return {
40
+ directories,
41
+ marked: directories.filter((name) => fs.existsSync(path.join(contentDir, name, ARCHIVE_MARKER)))
42
+ };
43
+ }
@@ -0,0 +1,18 @@
1
+ export type ArchiveVersionOptions = {
2
+ /** Id of the archive to create. Becomes its directory name and URL segment. */
3
+ id: string;
4
+ /** Switcher label for the archive. Defaults to the id. */
5
+ label?: string;
6
+ /** Docs content directory, resolved against `cwd`. */
7
+ content?: string;
8
+ /** SvelteKit routes directory, resolved against `cwd`. */
9
+ routes?: string;
10
+ /** Working directory the relative options resolve against. */
11
+ cwd?: string;
12
+ };
13
+ /**
14
+ * Copy the docs root into `<content>/<id>/`, mark it as an archive, and rewrite
15
+ * each copied page so it stays inside the archive and keeps its real
16
+ * last-updated date. Returns the number of pages archived.
17
+ */
18
+ export declare function archiveVersion(options: ArchiveVersionOptions, log: (line: string) => void): number;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `svelte-docsmith archive-version <id>`: freeze the current docs into an
3
+ * archived version folder so they keep serving the release they document while
4
+ * the docs root goes on being edited.
5
+ *
6
+ * The pure text transforms live in `../archive.js`; this is the filesystem work
7
+ * around them. It takes its working directory and its output sink rather than
8
+ * reading `process.cwd()` and calling `console.log`, so the whole command runs
9
+ * in-process under test. See `docs/adr/0002-archives-are-rewritten-source-copies.md`.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { assertValidVersionId } from '../../core/version.js';
14
+ import { freezeLastUpdated, isInheritedRouteFile, rewriteDocsLinks } from '../archive.js';
15
+ import { ARCHIVE_MARKER, discoverArchives, markerContents } from '../archives.js';
16
+ import { lastCommitDate } from '../git.js';
17
+ import { docsBaseFrom } from '../paths.js';
18
+ import { isPageFile } from '../vite/pages.js';
19
+ import { CliError } from './error.js';
20
+ /**
21
+ * Copy the docs root into `<content>/<id>/`, mark it as an archive, and rewrite
22
+ * each copied page so it stays inside the archive and keeps its real
23
+ * last-updated date. Returns the number of pages archived.
24
+ */
25
+ export function archiveVersion(options, log) {
26
+ const cwd = options.cwd ?? process.cwd();
27
+ const { id, label = options.id } = options;
28
+ try {
29
+ assertValidVersionId(id);
30
+ }
31
+ catch (error) {
32
+ throw new CliError(error.message);
33
+ }
34
+ const contentDir = path.resolve(cwd, options.content ?? 'src/routes/docs');
35
+ const routesDir = path.resolve(cwd, options.routes ?? 'src/routes');
36
+ const toDir = path.join(contentDir, id);
37
+ const rel = (p) => path.relative(cwd, p);
38
+ if (!fs.existsSync(contentDir)) {
39
+ throw new CliError(`content directory not found: ${rel(contentDir)}`);
40
+ }
41
+ if (fs.existsSync(toDir)) {
42
+ throw new CliError(`target already exists: ${rel(toDir)}`);
43
+ }
44
+ const docsBase = docsBaseFrom(routesDir, contentDir);
45
+ // Archives already on disk, so this run neither copies them into the new
46
+ // archive nor rewrites links that already point into one of them.
47
+ const archivedIds = new Set(discoverArchives(contentDir).marked);
48
+ /** Copy the docs root into the archive, skipping what the archive shouldn't hold. */
49
+ function copyCurrent(from, to) {
50
+ fs.mkdirSync(to, { recursive: true });
51
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
52
+ const src = path.join(from, entry.name);
53
+ const dest = path.join(to, entry.name);
54
+ if (entry.isDirectory()) {
55
+ // Never descend into the archive being written (it lives inside the docs
56
+ // root) or into an archive written by an earlier run.
57
+ if (src === toDir)
58
+ continue;
59
+ if (from === contentDir && archivedIds.has(entry.name))
60
+ continue;
61
+ copyCurrent(src, dest);
62
+ }
63
+ else {
64
+ // The root layout and error page already apply to the archive, which is
65
+ // nested inside them; copying them would nest a second DocsShell.
66
+ if (from === contentDir && isInheritedRouteFile(entry.name))
67
+ continue;
68
+ fs.copyFileSync(src, dest);
69
+ }
70
+ }
71
+ }
72
+ /** Every file in the archive, paired with the source file it was copied from. */
73
+ function* eachCopiedFile(dir = toDir) {
74
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
75
+ const file = path.join(dir, entry.name);
76
+ if (entry.isDirectory())
77
+ yield* eachCopiedFile(file);
78
+ else
79
+ yield { file, source: path.join(contentDir, path.relative(toDir, file)) };
80
+ }
81
+ }
82
+ copyCurrent(contentDir, toDir);
83
+ fs.writeFileSync(path.join(toDir, ARCHIVE_MARKER), markerContents(id));
84
+ let pages = 0;
85
+ for (const { file, source } of eachCopiedFile()) {
86
+ if (!isPageFile(path.basename(file)))
87
+ continue;
88
+ pages++;
89
+ const text = fs.readFileSync(file, 'utf-8');
90
+ fs.writeFileSync(file, freezeLastUpdated(rewriteDocsLinks(text, { docsBase, versionId: id, archivedIds }), lastCommitDate(source)));
91
+ }
92
+ log(`\n✓ Archived the current docs into ${rel(toDir)} (${pages} pages)\n`);
93
+ log('Links were rewritten to stay inside the archive, and each page kept');
94
+ log('its real last-updated date. Review the diff, then update');
95
+ log('docsmith({ versions }) so the archive is served:\n');
96
+ log(' versions: {');
97
+ log(" current: { id: '<new release>', label: '<new release>' },");
98
+ log(` archived: [{ id: '${id}', label: '${label}' }${archivedIds.size ? ', …' : ''}]`);
99
+ log(' }\n');
100
+ // Until that paste lands the build fails, by design: the archive is on disk
101
+ // and undeclared. See docs/adr/0003-the-archive-marker-defines-an-archive.md.
102
+ log('Until then the build will fail, reporting this archive as undeclared.\n');
103
+ return pages;
104
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A failure the user caused and can fix: a missing argument, a bad id, a target
3
+ * that already exists. The `bin/` shim prints its message and exits 1, without
4
+ * a stack, because a stack is noise for these. Anything else thrown out of the
5
+ * command is a bug and keeps its stack.
6
+ */
7
+ export declare class CliError extends Error {
8
+ readonly name = "CliError";
9
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A failure the user caused and can fix: a missing argument, a bad id, a target
3
+ * that already exists. The `bin/` shim prints its message and exits 1, without
4
+ * a stack, because a stack is noise for these. Anything else thrown out of the
5
+ * command is a bug and keeps its stack.
6
+ */
7
+ export class CliError extends Error {
8
+ name = 'CliError';
9
+ }
@@ -0,0 +1,13 @@
1
+ export { CliError } from './error.js';
2
+ export { archiveVersion } from './archive-version.js';
3
+ export declare const HELP = "svelte-docsmith \u2014 docs maintenance CLI\n\nUsage:\n svelte-docsmith archive-version <id> [options]\n\nFreeze the current docs into an archived version folder. The archive keeps\nserving the release it documents while you go on editing the docs root.\n\nOptions:\n --label <label> switcher label for the archive (default: the id)\n --content <dir> docs content directory (default: src/routes/docs)\n --routes <dir> SvelteKit routes directory (default: src/routes)\n";
4
+ /** Where the command writes, and what it resolves relative paths against. */
5
+ export type CliIo = {
6
+ cwd?: string;
7
+ log?: (line: string) => void;
8
+ };
9
+ /**
10
+ * Run the CLI over `argv` (everything after the node binary and script). Throws
11
+ * {@link CliError} for anything the user can fix; the caller owns the exit code.
12
+ */
13
+ export declare function run(argv: string[], io?: CliIo): void;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The `svelte-docsmith` maintenance CLI. Currently one command:
3
+ * `archive-version`.
4
+ *
5
+ * This is the whole command, not a helper for it: `bin/svelte-docsmith.mjs` is a
6
+ * shim that hands over `process.argv` and turns a thrown {@link CliError} into a
7
+ * message and an exit code. Keeping the process boundary out here means the
8
+ * command is typechecked, and testable in-process against a temporary directory
9
+ * rather than by spawning node and parsing stdout.
10
+ */
11
+ import { archiveVersion } from './archive-version.js';
12
+ import { CliError } from './error.js';
13
+ export { CliError } from './error.js';
14
+ export { archiveVersion } from './archive-version.js';
15
+ export const HELP = `svelte-docsmith — docs maintenance CLI
16
+
17
+ Usage:
18
+ svelte-docsmith archive-version <id> [options]
19
+
20
+ Freeze the current docs into an archived version folder. The archive keeps
21
+ serving the release it documents while you go on editing the docs root.
22
+
23
+ Options:
24
+ --label <label> switcher label for the archive (default: the id)
25
+ --content <dir> docs content directory (default: src/routes/docs)
26
+ --routes <dir> SvelteKit routes directory (default: src/routes)
27
+ `;
28
+ /** Parse `archive-version`'s flags and its one positional id. */
29
+ function parseArchiveArgs(rest) {
30
+ const options = {};
31
+ for (let i = 0; i < rest.length; i++) {
32
+ const arg = rest[i];
33
+ if (arg === '--label')
34
+ options.label = rest[++i];
35
+ else if (arg === '--content')
36
+ options.content = rest[++i];
37
+ else if (arg === '--routes')
38
+ options.routes = rest[++i];
39
+ else if (!arg.startsWith('--') && !options.id)
40
+ options.id = arg;
41
+ }
42
+ if (!options.id) {
43
+ throw new CliError('missing <id>. e.g. `svelte-docsmith archive-version v1`');
44
+ }
45
+ return options;
46
+ }
47
+ /**
48
+ * Run the CLI over `argv` (everything after the node binary and script). Throws
49
+ * {@link CliError} for anything the user can fix; the caller owns the exit code.
50
+ */
51
+ export function run(argv, io = {}) {
52
+ const log = io.log ?? ((line) => console.log(line));
53
+ const [command, ...rest] = argv;
54
+ if (!command) {
55
+ log(HELP);
56
+ return;
57
+ }
58
+ if (command !== 'archive-version') {
59
+ throw new CliError(`unknown command: ${command}\n\n${HELP}`);
60
+ }
61
+ archiveVersion({ ...parseArchiveArgs(rest), cwd: io.cwd }, log);
62
+ }
@@ -1,4 +1,4 @@
1
- /** Last git commit date (strict ISO) for a file, or undefined outside a repo. */
1
+ /** Commit day (`YYYY-MM-DD`) a file last changed, or undefined outside a repo. */
2
2
  export declare function lastCommitDate(file: string): string | undefined;
3
3
  /**
4
4
  * Release dates keyed by version, read from the commit that introduced each
@@ -1,8 +1,20 @@
1
+ /**
2
+ * The git queries the build makes: when a page last changed, and when each
3
+ * release landed. Used by both the vite plugin and the `archive-version`
4
+ * command, which is why it sits here rather than under `vite/`.
5
+ *
6
+ * Every date here is `%cs`, the commit's calendar day (`YYYY-MM-DD`), never a
7
+ * timestamp. Dates from this module are rendered as a day and nothing more, and
8
+ * a date-only string parses to UTC midnight, so pinning the formatter to UTC
9
+ * renders the committer's own day identically for every reader and identically
10
+ * on the server and the client. A full timestamp buys precision no consumer
11
+ * uses and reintroduces the day-boundary drift.
12
+ */
1
13
  import { spawnSync } from 'node:child_process';
2
14
  import path from 'node:path';
3
- /** Last git commit date (strict ISO) for a file, or undefined outside a repo. */
15
+ /** Commit day (`YYYY-MM-DD`) a file last changed, or undefined outside a repo. */
4
16
  export function lastCommitDate(file) {
5
- const res = spawnSync('git', ['log', '-1', '--format=%cI', '--', file], {
17
+ const res = spawnSync('git', ['log', '-1', '--format=%cs', '--', file], {
6
18
  cwd: path.dirname(file),
7
19
  encoding: 'utf-8'
8
20
  });
@@ -19,7 +31,7 @@ export function changelogDates(file) {
19
31
  const dates = new Map();
20
32
  // `-L` would be per-line; instead walk the file's commits newest-first and
21
33
  // record the first commit in which each version heading appears.
22
- const log = spawnSync('git', ['log', '--format=%H %cI', '--reverse', '--follow', '--', path.basename(file)], { cwd: path.dirname(file), encoding: 'utf-8' });
34
+ const log = spawnSync('git', ['log', '--format=%H %cs', '--reverse', '--follow', '--', path.basename(file)], { cwd: path.dirname(file), encoding: 'utf-8' });
23
35
  if (log.status !== 0)
24
36
  return dates;
25
37
  for (const line of log.stdout.trim().split('\n').filter(Boolean)) {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The docs URL base, e.g. `/docs`, from the content directory's location under
3
+ * the routes directory. This is the same mapping the collectors use to turn a
4
+ * page's directory into its URL, so a page at `<routes>/docs/intro/+page.md` is
5
+ * served at `/docs/intro` under a base of `/docs`.
6
+ */
7
+ export declare function docsBaseFrom(routesDir: string, contentDir: string): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Build-time path derivations shared by the vite plugin and the maintenance CLI.
3
+ * Both have to agree on where the docs live in URL space, and each deriving it
4
+ * separately is how they drift.
5
+ */
6
+ import path from 'node:path';
7
+ /**
8
+ * The docs URL base, e.g. `/docs`, from the content directory's location under
9
+ * the routes directory. This is the same mapping the collectors use to turn a
10
+ * page's directory into its URL, so a page at `<routes>/docs/intro/+page.md` is
11
+ * served at `/docs/intro` under a base of `/docs`.
12
+ */
13
+ export function docsBaseFrom(routesDir, contentDir) {
14
+ return '/' + path.relative(routesDir, contentDir).split(path.sep).join('/');
15
+ }
@@ -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
@@ -19,7 +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 { resolveVersions } from '../../core/version.js';
22
+ import { checkVersions, resolveVersions } from '../../core/version.js';
23
+ import { ARCHIVE_MARKER, discoverArchives } from '../archives.js';
24
+ import { docsBaseFrom } from '../paths.js';
23
25
  import { isPageFile, listPageFiles } from './pages.js';
24
26
  import { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
25
27
  import { collectReleases } from './releases.js';
@@ -49,9 +51,7 @@ function contentIndexPlugin(options) {
49
51
  const contentDir = path.resolve(options.content ?? 'src/routes/docs');
50
52
  const routesDir = path.resolve(options.routes ?? 'src/routes');
51
53
  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('/');
54
+ const docsBase = docsBaseFrom(routesDir, contentDir);
55
55
  const changelogFile = options.changelog === false ? undefined : path.resolve(options.changelog ?? 'CHANGELOG.md');
56
56
  const changelogRoute = options.changelogPath ?? '/changelog';
57
57
  const changelogOverrides = path.join(routesDir, changelogRoute.replace(/^\//, ''));
@@ -76,6 +76,11 @@ function contentIndexPlugin(options) {
76
76
  if (id === VIRTUAL_CONTENT_ID) {
77
77
  for (const file of listPageFiles(contentDir))
78
78
  this.addWatchFile(file);
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);
79
84
  const docs = collectDocs(contentDir, routesDir, versions);
80
85
  const resolved = resolveVersions(versions, docsBase, docs);
81
86
  return (`export const docs = ${JSON.stringify(docs, null, 2)};\n` +
@@ -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
  );
@@ -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';
@@ -26,6 +27,10 @@
26
27
  } = $props();
27
28
 
28
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);
29
34
 
30
35
  let query = $state('');
31
36
  let engine = $state<SearchEngine | null>(null);
@@ -36,14 +41,12 @@
36
41
  let engineVersion: string | undefined;
37
42
 
38
43
  const trimmed = $derived(query.trim());
39
- // Scope to the active version when the shell set one (versioned sites).
40
- const results = $derived(
41
- engine && trimmed ? engine.search(query, undefined, search?.version) : []
42
- );
44
+ // Scope to the active version on a versioned site; `undefined` otherwise.
45
+ const results = $derived(engine && trimmed ? engine.search(query, undefined, activeVersion) : []);
43
46
 
44
47
  // Build the index the first time the palette opens; keep it for later opens.
45
48
  async function ensureEngine() {
46
- const version = search?.version;
49
+ const version = activeVersion;
47
50
  if ((engine && engineVersion === version) || status === 'loading') return;
48
51
  status = 'loading';
49
52
  try {