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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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);
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
@@ -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,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
|
-
/**
|
|
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
|
-
/**
|
|
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=%
|
|
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 %
|
|
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,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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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[];
|