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.
- package/bin/svelte-docsmith.mjs +161 -0
- package/dist/buildtime/archive.d.ts +31 -0
- package/dist/buildtime/archive.js +50 -0
- package/dist/buildtime/markdown-source.d.ts +31 -0
- package/dist/buildtime/markdown-source.js +90 -0
- package/dist/buildtime/vite/collect.d.ts +4 -3
- package/dist/buildtime/vite/collect.js +31 -12
- 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 +11 -4
- package/dist/components/chrome/copy-button.svelte +3 -2
- package/dist/components/chrome/search.svelte +16 -4
- package/dist/components/chrome/search.svelte.d.ts +4 -1
- package/dist/components/chrome/version-banner.svelte +82 -0
- package/dist/components/chrome/version-banner.svelte.d.ts +10 -0
- package/dist/components/chrome/version-switcher.svelte +63 -0
- package/dist/components/chrome/version-switcher.svelte.d.ts +14 -0
- package/dist/components/layouts/docs-header.svelte +26 -3
- package/dist/components/layouts/docs-header.svelte.d.ts +9 -1
- package/dist/components/layouts/docs-mobile-header.svelte +25 -2
- package/dist/components/layouts/docs-mobile-header.svelte.d.ts +9 -1
- package/dist/components/layouts/docs-shell.svelte +84 -66
- 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/content.d.ts +9 -0
- package/dist/core/docs-page.d.ts +70 -0
- package/dist/core/docs-page.js +55 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/version.d.ts +80 -0
- package/dist/core/version.js +85 -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/context.svelte.d.ts +2 -0
- package/dist/search/context.svelte.js +2 -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,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
|
-
|
|
86
|
+
// Frontmatter wins over git so an archived page keeps the date it last
|
|
87
|
+
// really changed: archiving copies every page in one commit, which would
|
|
88
|
+
// otherwise stamp the whole archive with the day it was created.
|
|
89
|
+
lastUpdated: typeof front.lastUpdated === 'string' ? front.lastUpdated : lastCommitDate(file),
|
|
74
90
|
readingTime: readingMinutes(extractSearchText(source)),
|
|
75
|
-
toc: extractToc(source)
|
|
91
|
+
toc: extractToc(source),
|
|
92
|
+
version
|
|
76
93
|
});
|
|
77
94
|
}
|
|
78
95
|
if (items.length === 0) {
|
|
@@ -89,18 +106,19 @@ export function collectDocs(contentDir, routesDir) {
|
|
|
89
106
|
* bloating the eagerly-imported nav index. The missing-directory case is
|
|
90
107
|
* already reported by {@link collectDocs}, so this stays quiet.
|
|
91
108
|
*/
|
|
92
|
-
export function collectSearchDocs(contentDir, routesDir) {
|
|
109
|
+
export function collectSearchDocs(contentDir, routesDir, versions) {
|
|
93
110
|
if (!fs.existsSync(contentDir))
|
|
94
111
|
return [];
|
|
95
112
|
const docs = [];
|
|
96
|
-
for (const { source, front, url, title } of eachTitledPage(contentDir, routesDir)) {
|
|
113
|
+
for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
|
|
97
114
|
docs.push({
|
|
98
115
|
path: url,
|
|
99
116
|
title,
|
|
100
117
|
section: sectionLabel(front.section),
|
|
101
118
|
description: typeof front.description === 'string' ? front.description : undefined,
|
|
102
119
|
headings: extractToc(source).map((entry) => entry.title),
|
|
103
|
-
text: extractSearchText(source)
|
|
120
|
+
text: extractSearchText(source),
|
|
121
|
+
version
|
|
104
122
|
});
|
|
105
123
|
}
|
|
106
124
|
return docs.sort((a, b) => a.path.localeCompare(b.path));
|
|
@@ -111,18 +129,19 @@ export function collectSearchDocs(contentDir, routesDir) {
|
|
|
111
129
|
* consumed server-side by `llms.txt` / `llms-full.txt` routes, so it never ships
|
|
112
130
|
* to the client.
|
|
113
131
|
*/
|
|
114
|
-
export function collectLlmsDocs(contentDir, routesDir) {
|
|
132
|
+
export function collectLlmsDocs(contentDir, routesDir, versions) {
|
|
115
133
|
if (!fs.existsSync(contentDir))
|
|
116
134
|
return [];
|
|
117
135
|
const docs = [];
|
|
118
|
-
for (const { source, front, url, title } of eachTitledPage(contentDir, routesDir)) {
|
|
136
|
+
for (const { source, front, url, title, version } of eachTitledPage(contentDir, routesDir, versions)) {
|
|
119
137
|
docs.push({
|
|
120
138
|
path: url,
|
|
121
139
|
title,
|
|
122
140
|
section: sectionKey(front.section),
|
|
123
141
|
order: typeof front.order === 'number' ? front.order : undefined,
|
|
124
142
|
description: typeof front.description === 'string' ? front.description : undefined,
|
|
125
|
-
content: extractLlmsContent(source, title)
|
|
143
|
+
content: extractLlmsContent(source, title),
|
|
144
|
+
version
|
|
126
145
|
});
|
|
127
146
|
}
|
|
128
147
|
return docs.sort((a, b) => a.path.localeCompare(b.path));
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import GithubSlugger from 'github-slugger';
|
|
2
|
+
import { proseLines, splitFrontmatter } from '../markdown-source.js';
|
|
3
|
+
/** Drop a page's `<script>`/`<style>` blocks: component setup, not content. */
|
|
4
|
+
function withoutSetupBlocks(body) {
|
|
5
|
+
return body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
6
|
+
}
|
|
2
7
|
/** Strip inline markdown so a heading's TOC label is plain text. */
|
|
3
8
|
export function stripInlineMarkdown(text) {
|
|
4
9
|
return text
|
|
@@ -17,23 +22,9 @@ export function stripInlineMarkdown(text) {
|
|
|
17
22
|
* samples are intentionally dropped to keep the index small and prose-focused.
|
|
18
23
|
*/
|
|
19
24
|
export function extractSearchText(source) {
|
|
20
|
-
|
|
21
|
-
// Component/setup blocks aren't prose; drop them whole before line scanning.
|
|
22
|
-
body = body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
25
|
+
const body = withoutSetupBlocks(splitFrontmatter(source).body);
|
|
23
26
|
const out = [];
|
|
24
|
-
|
|
25
|
-
for (const line of body.split('\n')) {
|
|
26
|
-
const f = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
27
|
-
if (f) {
|
|
28
|
-
const ch = f[1][0];
|
|
29
|
-
if (fence === null)
|
|
30
|
-
fence = ch;
|
|
31
|
-
else if (ch === fence)
|
|
32
|
-
fence = null;
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
if (fence !== null)
|
|
36
|
-
continue;
|
|
27
|
+
for (const line of proseLines(body)) {
|
|
37
28
|
// Skip table delimiter rows (`| --- | :--: |`) — pure structure, no words.
|
|
38
29
|
if (/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
|
|
39
30
|
continue;
|
|
@@ -63,22 +54,9 @@ export function readingMinutes(text) {
|
|
|
63
54
|
* heading ids exactly, not just for common cases.
|
|
64
55
|
*/
|
|
65
56
|
export function extractToc(source) {
|
|
66
|
-
const body = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
|
67
57
|
const slugger = new GithubSlugger();
|
|
68
58
|
const toc = [];
|
|
69
|
-
|
|
70
|
-
for (const line of body.split('\n')) {
|
|
71
|
-
const f = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
72
|
-
if (f) {
|
|
73
|
-
const ch = f[1][0];
|
|
74
|
-
if (fence === null)
|
|
75
|
-
fence = ch;
|
|
76
|
-
else if (ch === fence)
|
|
77
|
-
fence = null;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (fence !== null)
|
|
81
|
-
continue;
|
|
59
|
+
for (const line of proseLines(splitFrontmatter(source).body)) {
|
|
82
60
|
const m = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
83
61
|
if (!m)
|
|
84
62
|
continue;
|
|
@@ -95,10 +73,6 @@ export function extractToc(source) {
|
|
|
95
73
|
* with the frontmatter title prepended as an `h1` (pages start their body at h2).
|
|
96
74
|
*/
|
|
97
75
|
export function extractLlmsContent(source, title) {
|
|
98
|
-
const body = source
|
|
99
|
-
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
|
|
100
|
-
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
101
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
102
|
-
.trim();
|
|
76
|
+
const body = withoutSetupBlocks(splitFrontmatter(source).body).trim();
|
|
103
77
|
return `# ${title}\n\n${body}`;
|
|
104
78
|
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import yaml from 'js-yaml';
|
|
2
|
+
import { splitFrontmatter } from '../markdown-source.js';
|
|
2
3
|
/**
|
|
3
4
|
* Parse a page's leading YAML frontmatter block into a plain object. Returns an
|
|
4
5
|
* empty object when there is no frontmatter; throws a located error on invalid
|
|
5
6
|
* YAML so a typo surfaces with its filename instead of a blank page.
|
|
6
7
|
*/
|
|
7
8
|
export function parseFrontmatter(source, file) {
|
|
8
|
-
const
|
|
9
|
-
if (
|
|
9
|
+
const { frontmatter } = splitFrontmatter(source);
|
|
10
|
+
if (frontmatter === undefined)
|
|
10
11
|
return {};
|
|
11
12
|
let data;
|
|
12
13
|
try {
|
|
13
|
-
data = yaml.load(
|
|
14
|
+
data = yaml.load(frontmatter);
|
|
14
15
|
}
|
|
15
16
|
catch (err) {
|
|
16
17
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
+
import { type DocsVersions } from '../../core/version.js';
|
|
2
3
|
export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
|
|
3
4
|
export { collectReleases } from './releases.js';
|
|
4
5
|
export interface DocsmithViteOptions {
|
|
@@ -26,6 +27,14 @@ export interface DocsmithViteOptions {
|
|
|
26
27
|
* hand-written per-release pages. Default: `'/changelog'`.
|
|
27
28
|
*/
|
|
28
29
|
changelogPath?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Declare documentation versions to enable versioned docs. `current` is the
|
|
32
|
+
* docs for the latest release: its pages sit directly under `<content>` and
|
|
33
|
+
* keep their unprefixed URLs. Each archived version's pages live under
|
|
34
|
+
* `<content>/<id>/` and are served at `/docs/<id>/…`. Omit for a single,
|
|
35
|
+
* unversioned docs tree (the default).
|
|
36
|
+
*/
|
|
37
|
+
versions?: DocsVersions;
|
|
29
38
|
}
|
|
30
39
|
/**
|
|
31
40
|
* The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
|
|
@@ -19,6 +19,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
|
-
|
|
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) {
|