svelte-docsmith 0.11.0 → 0.12.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/dist/buildtime/archive.js +5 -3
- package/dist/buildtime/paths.d.ts +8 -3
- package/dist/buildtime/paths.js +12 -4
- package/dist/buildtime/vite/collect.d.ts +25 -16
- package/dist/buildtime/vite/collect.js +100 -106
- package/dist/buildtime/vite/extract.d.ts +0 -2
- package/dist/buildtime/vite/extract.js +1 -1
- package/dist/buildtime/vite/index.d.ts +0 -2
- package/dist/buildtime/vite/index.js +41 -22
- package/dist/buildtime/vite/pages.d.ts +51 -0
- package/dist/buildtime/vite/pages.js +58 -0
- package/dist/buildtime/vite/releases.d.ts +0 -2
- package/dist/buildtime/vite/releases.js +0 -5
- package/dist/components/layouts/copy-page-menu.svelte +4 -2
- package/dist/components/layouts/docs-header.svelte +9 -5
- package/dist/components/layouts/docs-sidebar.svelte +1 -1
- package/dist/components/layouts/seo-head.svelte +7 -5
- package/dist/core/changelog.d.ts +3 -0
- package/dist/core/docs-page.js +2 -4
- package/dist/core/version.js +5 -9
- package/dist/generate/feed.d.ts +4 -1
- package/dist/generate/feed.js +7 -19
- package/dist/generate/llms.d.ts +4 -1
- package/dist/generate/llms.js +3 -2
- package/dist/generate/sitemap.d.ts +4 -1
- package/dist/generate/sitemap.js +3 -16
- package/dist/generate/xml.d.ts +12 -0
- package/dist/generate/xml.js +27 -0
- package/dist/utils/url.d.ts +54 -0
- package/dist/utils/url.js +84 -0
- package/package.json +1 -1
- package/dist/utils/normalize-path.d.ts +0 -6
- package/dist/utils/normalize-path.js +0 -8
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Kept here rather than in `bin/` so they are typechecked and unit-tested; the
|
|
8
8
|
* CLI is a thin wrapper over them. See `docs/adr/0002-archives-are-rewritten-source-copies.md`.
|
|
9
9
|
*/
|
|
10
|
+
import { atBoundary, firstSegmentUnder } from '../utils/url.js';
|
|
10
11
|
import { outsideCodeFences, withFrontmatter } from './markdown-source.js';
|
|
11
12
|
/** Route files at the docs root that an archive nested inside it already inherits. */
|
|
12
13
|
export function isInheritedRouteFile(name) {
|
|
@@ -28,10 +29,11 @@ export function rewriteDocsLinks(text, options) {
|
|
|
28
29
|
const skip = new Set([...(options.archivedIds ?? []), versionId]);
|
|
29
30
|
const pattern = new RegExp(`(\\]\\(|href="|href='|\\]:[ \\t]+)(${escapeRe(docsBase)})([^)"'\\s]*)`, 'g');
|
|
30
31
|
return outsideCodeFences(text, (chunk) => chunk.replace(pattern, (match, prefix, base, rest) => {
|
|
31
|
-
//
|
|
32
|
-
|
|
32
|
+
// The regex has already matched the base, so guard the boundary on what
|
|
33
|
+
// follows it: `/docsmith` must not become `/docs/v1mith`.
|
|
34
|
+
if (!atBoundary(rest))
|
|
33
35
|
return match;
|
|
34
|
-
const firstSegment =
|
|
36
|
+
const firstSegment = firstSegmentUnder(rest, '');
|
|
35
37
|
if (firstSegment && skip.has(firstSegment))
|
|
36
38
|
return match;
|
|
37
39
|
return `${prefix}${base}/${versionId}${rest}`;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL a directory under the routes root is served at: `<routes>/docs/intro`
|
|
3
|
+
* becomes `/docs/intro`. The routes root itself becomes `/`.
|
|
4
|
+
*/
|
|
5
|
+
export declare function urlFor(routesDir: string, dir: string): string;
|
|
1
6
|
/**
|
|
2
7
|
* The docs URL base, e.g. `/docs`, from the content directory's location under
|
|
3
|
-
* the routes directory.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
8
|
+
* the routes directory. The same mapping a page's own directory goes through, so
|
|
9
|
+
* a page at `<routes>/docs/intro/+page.md` is served at `/docs/intro` under a
|
|
10
|
+
* base of `/docs`.
|
|
6
11
|
*/
|
|
7
12
|
export declare function docsBaseFrom(routesDir: string, contentDir: string): string;
|
package/dist/buildtime/paths.js
CHANGED
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
* separately is how they drift.
|
|
5
5
|
*/
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
/**
|
|
8
|
+
* The URL a directory under the routes root is served at: `<routes>/docs/intro`
|
|
9
|
+
* becomes `/docs/intro`. The routes root itself becomes `/`.
|
|
10
|
+
*/
|
|
11
|
+
export function urlFor(routesDir, dir) {
|
|
12
|
+
const rel = path.relative(routesDir, dir).split(path.sep).join('/');
|
|
13
|
+
return '/' + rel;
|
|
14
|
+
}
|
|
7
15
|
/**
|
|
8
16
|
* The docs URL base, e.g. `/docs`, from the content directory's location under
|
|
9
|
-
* the routes directory.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
17
|
+
* the routes directory. The same mapping a page's own directory goes through, so
|
|
18
|
+
* a page at `<routes>/docs/intro/+page.md` is served at `/docs/intro` under a
|
|
19
|
+
* base of `/docs`.
|
|
12
20
|
*/
|
|
13
21
|
export function docsBaseFrom(routesDir, contentDir) {
|
|
14
|
-
return
|
|
22
|
+
return urlFor(routesDir, contentDir);
|
|
15
23
|
}
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import type { DocsContentItem, LlmsDoc, SearchDoc } from '../../core/content.js';
|
|
2
2
|
import type { DocsVersions } from '../../core/version.js';
|
|
3
|
+
import { type SourcePage } from './pages.js';
|
|
4
|
+
/** Where the pages sit, which is what turns a page's file into a URL and a version. */
|
|
5
|
+
export type IndexOptions = {
|
|
6
|
+
/** The docs root the pages were read from. */
|
|
7
|
+
contentDir: string;
|
|
8
|
+
/** Routes root, so `<routes>/docs/intro/+page.md` becomes `/docs/intro`. */
|
|
9
|
+
routesDir: string;
|
|
10
|
+
/** Declared versions, when the site has them. */
|
|
11
|
+
versions?: DocsVersions;
|
|
12
|
+
};
|
|
3
13
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
14
|
+
* The sidebar's index: the frontmatter fields navigation needs, plus the heading
|
|
15
|
+
* list for a server-rendered TOC and an estimated reading time. Served as the
|
|
16
|
+
* eagerly-imported `svelte-docsmith/content` virtual module.
|
|
17
|
+
*
|
|
18
|
+
* `lastUpdated` is read off the page rather than looked up here, so pass pages
|
|
19
|
+
* that have been through `withCommitDates` or the column comes out blank.
|
|
8
20
|
*/
|
|
9
|
-
export declare function
|
|
21
|
+
export declare function contentIndex(pages: SourcePage[], options: IndexOptions): DocsContentItem[];
|
|
10
22
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* bloating the eagerly-imported nav index. The missing-directory case is
|
|
15
|
-
* already reported by {@link collectDocs}, so this stays quiet.
|
|
23
|
+
* The search index: title, section, description, heading list, and plain-text
|
|
24
|
+
* body. Served as the lazy-loaded `svelte-docsmith/search` virtual module so
|
|
25
|
+
* search can index bodies without bloating the eagerly-imported content index.
|
|
16
26
|
*/
|
|
17
|
-
export declare function
|
|
27
|
+
export declare function searchIndex(pages: SourcePage[], options: IndexOptions): SearchDoc[];
|
|
18
28
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* to the client.
|
|
29
|
+
* The LLM index: title, section, description, and the full markdown content.
|
|
30
|
+
* Served as the `svelte-docsmith/llms` virtual module and consumed server-side
|
|
31
|
+
* by `llms.txt` / `llms-full.txt` routes, so it never ships to the client.
|
|
23
32
|
*/
|
|
24
|
-
export declare function
|
|
33
|
+
export declare function llmsIndex(pages: SourcePage[], options: IndexOptions): LlmsDoc[];
|
|
@@ -1,9 +1,51 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The three generated indexes, each a projection of the same page list. Nothing
|
|
3
|
+
* here reads the filesystem or spawns a process: a build hands these functions
|
|
4
|
+
* pages read by `./pages.js`, and a test hands them literals. Both go down the
|
|
5
|
+
* same path, so what the sidebar, search and llms.txt show is decided in one
|
|
6
|
+
* place and can be checked without a docs root on disk.
|
|
7
|
+
*/
|
|
2
8
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
9
|
+
import { firstSegmentUnder } from '../../utils/url.js';
|
|
10
|
+
import { docsBaseFrom, urlFor } from '../paths.js';
|
|
11
|
+
import { titleOf } from './pages.js';
|
|
5
12
|
import { extractLlmsContent, extractSearchText, extractToc, readingMinutes } from './extract.js';
|
|
6
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Resolve every titled page against the site's layout. Shared by all three
|
|
15
|
+
* indexes so they can never disagree about which pages exist or where they live.
|
|
16
|
+
*/
|
|
17
|
+
function indexedPages(pages, options) {
|
|
18
|
+
const resolved = [];
|
|
19
|
+
const docsBase = docsBaseFrom(options.routesDir, options.contentDir);
|
|
20
|
+
for (const page of pages) {
|
|
21
|
+
const title = titleOf(page);
|
|
22
|
+
if (title === undefined)
|
|
23
|
+
continue;
|
|
24
|
+
const url = urlFor(options.routesDir, path.dirname(page.file));
|
|
25
|
+
resolved.push({ ...page, url, title, version: versionOf(url) });
|
|
26
|
+
}
|
|
27
|
+
// Stable output keeps the generated modules diff-friendly across rebuilds.
|
|
28
|
+
return resolved.sort((a, b) => a.url.localeCompare(b.url));
|
|
29
|
+
/**
|
|
30
|
+
* A page belongs to an archived version when the first segment of its URL below
|
|
31
|
+
* the docs base is that archive's id; everything else is the current version,
|
|
32
|
+
* which lives unprefixed at the docs base.
|
|
33
|
+
*
|
|
34
|
+
* Decided in URL space, by the same rule `activeVersion` applies to the reader's
|
|
35
|
+
* pathname at runtime. Deciding it from the directory instead would be a second
|
|
36
|
+
* rule for the same question, in a second vocabulary, free to drift from the one
|
|
37
|
+
* the reader is actually served by. No versions ⇒ undefined, which keeps an
|
|
38
|
+
* unversioned site's index byte-for-byte what it was.
|
|
39
|
+
*/
|
|
40
|
+
function versionOf(url) {
|
|
41
|
+
const { versions } = options;
|
|
42
|
+
if (!versions)
|
|
43
|
+
return undefined;
|
|
44
|
+
const segment = firstSegmentUnder(url, docsBase);
|
|
45
|
+
const archived = versions.archived?.find((v) => v.id === segment);
|
|
46
|
+
return archived ? archived.id : versions.current.id;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
7
49
|
/**
|
|
8
50
|
* Read a page's `section` frontmatter: a string is one level, an array is a
|
|
9
51
|
* nested group path. Non-string array members are dropped; anything else is
|
|
@@ -33,116 +75,68 @@ function sectionKey(value) {
|
|
|
33
75
|
return Array.isArray(section) ? section.join(' / ') : section;
|
|
34
76
|
}
|
|
35
77
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* built from a single read of each file.
|
|
78
|
+
* A frontmatter field, when it holds the type the index expects. Frontmatter is
|
|
79
|
+
* whatever the author typed, so a field of the wrong type is dropped rather than
|
|
80
|
+
* carried into the index as one.
|
|
40
81
|
*/
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const dir = path.dirname(file);
|
|
48
|
-
const url = '/' + path.relative(routesDir, dir).split(path.sep).join('/');
|
|
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;
|
|
63
|
-
}
|
|
82
|
+
function stringField(front, key) {
|
|
83
|
+
return typeof front[key] === 'string' ? front[key] : undefined;
|
|
84
|
+
}
|
|
85
|
+
/** As {@link stringField}, for the numeric fields. */
|
|
86
|
+
function numberField(front, key) {
|
|
87
|
+
return typeof front[key] === 'number' ? front[key] : undefined;
|
|
64
88
|
}
|
|
65
89
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
90
|
+
* The sidebar's index: the frontmatter fields navigation needs, plus the heading
|
|
91
|
+
* list for a server-rendered TOC and an estimated reading time. Served as the
|
|
92
|
+
* eagerly-imported `svelte-docsmith/content` virtual module.
|
|
93
|
+
*
|
|
94
|
+
* `lastUpdated` is read off the page rather than looked up here, so pass pages
|
|
95
|
+
* that have been through `withCommitDates` or the column comes out blank.
|
|
70
96
|
*/
|
|
71
|
-
export function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
order: typeof front.order === 'number' ? front.order : undefined,
|
|
85
|
-
sourcePath: path.relative(process.cwd(), file).split(path.sep).join('/'),
|
|
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),
|
|
90
|
-
readingTime: readingMinutes(extractSearchText(source)),
|
|
91
|
-
toc: extractToc(source),
|
|
92
|
-
version
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
if (items.length === 0) {
|
|
96
|
-
console.warn(`[svelte-docsmith] no doc pages found under ${contentDir}\n` +
|
|
97
|
-
` Add \`+page.md\` files with at least a \`title:\` in their frontmatter to populate the sidebar.`);
|
|
98
|
-
}
|
|
99
|
-
// Stable output keeps the generated module diff-friendly across rebuilds.
|
|
100
|
-
return items.sort((a, b) => a.path.localeCompare(b.path));
|
|
97
|
+
export function contentIndex(pages, options) {
|
|
98
|
+
return indexedPages(pages, options).map((page) => ({
|
|
99
|
+
title: page.title,
|
|
100
|
+
path: page.url,
|
|
101
|
+
description: stringField(page.frontmatter, 'description'),
|
|
102
|
+
section: readSection(page.frontmatter.section),
|
|
103
|
+
order: numberField(page.frontmatter, 'order'),
|
|
104
|
+
sourcePath: path.relative(process.cwd(), page.file).split(path.sep).join('/'),
|
|
105
|
+
lastUpdated: page.lastUpdated,
|
|
106
|
+
readingTime: readingMinutes(extractSearchText(page.source)),
|
|
107
|
+
toc: extractToc(page.source),
|
|
108
|
+
version: page.version
|
|
109
|
+
}));
|
|
101
110
|
}
|
|
102
111
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* bloating the eagerly-imported nav index. The missing-directory case is
|
|
107
|
-
* already reported by {@link collectDocs}, so this stays quiet.
|
|
112
|
+
* The search index: title, section, description, heading list, and plain-text
|
|
113
|
+
* body. Served as the lazy-loaded `svelte-docsmith/search` virtual module so
|
|
114
|
+
* search can index bodies without bloating the eagerly-imported content index.
|
|
108
115
|
*/
|
|
109
|
-
export function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
headings: extractToc(source).map((entry) => entry.title),
|
|
120
|
-
text: extractSearchText(source),
|
|
121
|
-
version
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
return docs.sort((a, b) => a.path.localeCompare(b.path));
|
|
116
|
+
export function searchIndex(pages, options) {
|
|
117
|
+
return indexedPages(pages, options).map((page) => ({
|
|
118
|
+
path: page.url,
|
|
119
|
+
title: page.title,
|
|
120
|
+
section: sectionLabel(page.frontmatter.section),
|
|
121
|
+
description: stringField(page.frontmatter, 'description'),
|
|
122
|
+
headings: extractToc(page.source).map((entry) => entry.title),
|
|
123
|
+
text: extractSearchText(page.source),
|
|
124
|
+
version: page.version
|
|
125
|
+
}));
|
|
125
126
|
}
|
|
126
127
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* to the client.
|
|
128
|
+
* The LLM index: title, section, description, and the full markdown content.
|
|
129
|
+
* Served as the `svelte-docsmith/llms` virtual module and consumed server-side
|
|
130
|
+
* by `llms.txt` / `llms-full.txt` routes, so it never ships to the client.
|
|
131
131
|
*/
|
|
132
|
-
export function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
description: typeof front.description === 'string' ? front.description : undefined,
|
|
143
|
-
content: extractLlmsContent(source, title),
|
|
144
|
-
version
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
return docs.sort((a, b) => a.path.localeCompare(b.path));
|
|
132
|
+
export function llmsIndex(pages, options) {
|
|
133
|
+
return indexedPages(pages, options).map((page) => ({
|
|
134
|
+
path: page.url,
|
|
135
|
+
title: page.title,
|
|
136
|
+
section: sectionKey(page.frontmatter.section),
|
|
137
|
+
order: numberField(page.frontmatter, 'order'),
|
|
138
|
+
description: stringField(page.frontmatter, 'description'),
|
|
139
|
+
content: extractLlmsContent(page.source, page.title),
|
|
140
|
+
version: page.version
|
|
141
|
+
}));
|
|
148
142
|
}
|
|
@@ -3,8 +3,6 @@ export type TocEntry = {
|
|
|
3
3
|
title: string;
|
|
4
4
|
depth: 2 | 3;
|
|
5
5
|
};
|
|
6
|
-
/** Strip inline markdown so a heading's TOC label is plain text. */
|
|
7
|
-
export declare function stripInlineMarkdown(text: string): string;
|
|
8
6
|
/**
|
|
9
7
|
* Reduce a markdown page to plain, searchable body text: prose and heading text
|
|
10
8
|
* with frontmatter, `<script>`/`<style>` blocks, fenced code, HTML/Svelte tags,
|
|
@@ -5,7 +5,7 @@ function withoutSetupBlocks(body) {
|
|
|
5
5
|
return body.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
6
6
|
}
|
|
7
7
|
/** Strip inline markdown so a heading's TOC label is plain text. */
|
|
8
|
-
|
|
8
|
+
function stripInlineMarkdown(text) {
|
|
9
9
|
return text
|
|
10
10
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
11
11
|
.replace(/`([^`]+)`/g, '$1')
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
import { type DocsVersions } from '../../core/version.js';
|
|
3
|
-
export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
|
|
4
|
-
export { collectReleases } from './releases.js';
|
|
5
3
|
export interface DocsmithViteOptions {
|
|
6
4
|
/** Directory scanned for doc pages. Default: `'src/routes/docs'`. */
|
|
7
5
|
content?: string;
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* 3. The **`?source` transform** powering `LiveExample`: importing
|
|
14
14
|
* `Component.svelte?source` yields that file's Shiki-highlighted source.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Reading the docs root lives in `./pages.js` and building each index from what
|
|
17
|
+
* it read lives in `./collect.js`; this file owns the two Vite plugins, and is
|
|
18
|
+
* the only layer that reports to the console.
|
|
18
19
|
*/
|
|
19
20
|
import fs from 'node:fs';
|
|
20
21
|
import path from 'node:path';
|
|
@@ -22,11 +23,9 @@ import { DEFAULT_THEMES, lazyHighlighter } from '../highlight.js';
|
|
|
22
23
|
import { checkVersions, resolveVersions } from '../../core/version.js';
|
|
23
24
|
import { ARCHIVE_MARKER, discoverArchives } from '../archives.js';
|
|
24
25
|
import { docsBaseFrom } from '../paths.js';
|
|
25
|
-
import { isPageFile,
|
|
26
|
-
import {
|
|
26
|
+
import { isPageFile, readSourcePages, withCommitDates } from './pages.js';
|
|
27
|
+
import { contentIndex, llmsIndex, searchIndex } from './collect.js';
|
|
27
28
|
import { collectReleases } from './releases.js';
|
|
28
|
-
export { collectDocs, collectLlmsDocs, collectSearchDocs } from './collect.js';
|
|
29
|
-
export { collectReleases } from './releases.js';
|
|
30
29
|
/**
|
|
31
30
|
* The svelte-docsmith Vite plugin. Add it to `plugins` in `vite.config.ts`:
|
|
32
31
|
*
|
|
@@ -47,6 +46,21 @@ const LLMS_SPECIFIER = 'svelte-docsmith/llms';
|
|
|
47
46
|
const VIRTUAL_LLMS_ID = '\0svelte-docsmith:llms';
|
|
48
47
|
const CHANGELOG_SPECIFIER = 'svelte-docsmith/changelog';
|
|
49
48
|
const VIRTUAL_CHANGELOG_ID = '\0svelte-docsmith:changelog';
|
|
49
|
+
/**
|
|
50
|
+
* Tell the author when their docs root produced nothing, and why. An empty
|
|
51
|
+
* sidebar is otherwise silent: the build succeeds and the site renders, so
|
|
52
|
+
* without this the first sign of a misconfigured `content` is a missing nav.
|
|
53
|
+
*/
|
|
54
|
+
function warnAboutDocsRoot(contentDir, exists, indexed) {
|
|
55
|
+
if (!exists) {
|
|
56
|
+
console.warn(`[svelte-docsmith] content directory not found: ${contentDir}\n` +
|
|
57
|
+
` The sidebar will be empty. Create your doc pages there, or point docsmith() at the right place with \`content\`.`);
|
|
58
|
+
}
|
|
59
|
+
else if (indexed === 0) {
|
|
60
|
+
console.warn(`[svelte-docsmith] no doc pages found under ${contentDir}\n` +
|
|
61
|
+
` Add \`+page.md\` files with at least a \`title:\` in their frontmatter to populate the sidebar.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
50
64
|
function contentIndexPlugin(options) {
|
|
51
65
|
const contentDir = path.resolve(options.content ?? 'src/routes/docs');
|
|
52
66
|
const routesDir = path.resolve(options.routes ?? 'src/routes');
|
|
@@ -54,7 +68,8 @@ function contentIndexPlugin(options) {
|
|
|
54
68
|
const docsBase = docsBaseFrom(routesDir, contentDir);
|
|
55
69
|
const changelogFile = options.changelog === false ? undefined : path.resolve(options.changelog ?? 'CHANGELOG.md');
|
|
56
70
|
const changelogRoute = options.changelogPath ?? '/changelog';
|
|
57
|
-
const changelogOverrides = path.join(routesDir, changelogRoute
|
|
71
|
+
const changelogOverrides = path.join(routesDir, changelogRoute);
|
|
72
|
+
const indexOptions = { contentDir, routesDir, versions };
|
|
58
73
|
return {
|
|
59
74
|
name: 'docsmith-content',
|
|
60
75
|
enforce: 'pre',
|
|
@@ -73,30 +88,34 @@ function contentIndexPlugin(options) {
|
|
|
73
88
|
// body re-runs this load. A directory here is treated as an
|
|
74
89
|
// unresolvable import by vite:import-analysis; new/removed pages are
|
|
75
90
|
// handled by the watcher in configureServer.
|
|
91
|
+
const watch = (pages) => {
|
|
92
|
+
for (const page of pages)
|
|
93
|
+
this.addWatchFile(page.file);
|
|
94
|
+
};
|
|
76
95
|
if (id === VIRTUAL_CONTENT_ID) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// fail here rather than render. Re-runs on invalidation, so archiving
|
|
82
|
-
// during `dev` reports immediately.
|
|
96
|
+
// Before anything is read: a config that disagrees with the docs root
|
|
97
|
+
// produces an index that is wrong rather than empty, so it has to fail
|
|
98
|
+
// here rather than render. Re-runs on invalidation, so archiving during
|
|
99
|
+
// `dev` reports immediately.
|
|
83
100
|
checkVersions(versions, discoverArchives(contentDir), ARCHIVE_MARKER);
|
|
84
|
-
const
|
|
101
|
+
const { pages, exists } = readSourcePages(contentDir);
|
|
102
|
+
watch(pages);
|
|
103
|
+
// Only this index has a date field, so only this one pays for git.
|
|
104
|
+
const docs = contentIndex(withCommitDates(pages), indexOptions);
|
|
105
|
+
warnAboutDocsRoot(contentDir, exists, docs.length);
|
|
85
106
|
const resolved = resolveVersions(versions, docsBase, docs);
|
|
86
107
|
return (`export const docs = ${JSON.stringify(docs, null, 2)};\n` +
|
|
87
108
|
`export const versions = ${JSON.stringify(resolved, null, 2)};\n`);
|
|
88
109
|
}
|
|
89
110
|
if (id === VIRTUAL_SEARCH_ID) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const docs =
|
|
93
|
-
return `export const docs = ${JSON.stringify(docs)};\n`;
|
|
111
|
+
const { pages } = readSourcePages(contentDir);
|
|
112
|
+
watch(pages);
|
|
113
|
+
return `export const docs = ${JSON.stringify(searchIndex(pages, indexOptions))};\n`;
|
|
94
114
|
}
|
|
95
115
|
if (id === VIRTUAL_LLMS_ID) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const docs =
|
|
99
|
-
return `export const docs = ${JSON.stringify(docs)};\n`;
|
|
116
|
+
const { pages } = readSourcePages(contentDir);
|
|
117
|
+
watch(pages);
|
|
118
|
+
return `export const docs = ${JSON.stringify(llmsIndex(pages, indexOptions))};\n`;
|
|
100
119
|
}
|
|
101
120
|
if (id === VIRTUAL_CHANGELOG_ID) {
|
|
102
121
|
if (!changelogFile)
|
|
@@ -1,5 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One doc page as authored on disk, before any index-specific derivation. Its
|
|
3
|
+
* URL, version, headings and search text are all derived from these fields, so
|
|
4
|
+
* everything downstream of the read is pure.
|
|
5
|
+
*/
|
|
6
|
+
export type SourcePage = {
|
|
7
|
+
/** Absolute path to the page file. */
|
|
8
|
+
file: string;
|
|
9
|
+
/** The page's raw markdown, frontmatter included. */
|
|
10
|
+
source: string;
|
|
11
|
+
/** The page's parsed frontmatter, `{}` when it has none. */
|
|
12
|
+
frontmatter: Record<string, unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* The day (`YYYY-MM-DD`) the page last changed. Only {@link withCommitDates}
|
|
15
|
+
* fills this in, so it is absent on a page list that never went through it.
|
|
16
|
+
*/
|
|
17
|
+
lastUpdated?: string;
|
|
18
|
+
};
|
|
1
19
|
/** Whether a filename is a doc page the plugin should index. */
|
|
2
20
|
export declare function isPageFile(file: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Turn a page's path and raw markdown into a {@link SourcePage}. Pure, and
|
|
23
|
+
* shared with tests so an in-memory page is built exactly the way a read one is.
|
|
24
|
+
*/
|
|
25
|
+
export declare function toPage(file: string, source: string): SourcePage;
|
|
26
|
+
/**
|
|
27
|
+
* Read every doc page under `contentDir`. `exists` distinguishes a missing docs
|
|
28
|
+
* root from an empty one, which the page list alone cannot: both give no pages,
|
|
29
|
+
* but only one of them is a misconfiguration worth reporting.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readSourcePages(contentDir: string): {
|
|
32
|
+
pages: SourcePage[];
|
|
33
|
+
exists: boolean;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A page's title, when it has one. A page whose frontmatter has no string
|
|
37
|
+
* `title` is a stub: no index carries it, so nothing is derived for it and
|
|
38
|
+
* nothing is looked up about it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function titleOf(page: SourcePage): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Date each page from the commit that last touched it, skipping the lookup for
|
|
43
|
+
* pages that carry their own `lastUpdated`. Archiving copies every page in one
|
|
44
|
+
* commit, so an archive's pages are written with the date they last really
|
|
45
|
+
* changed and must keep it.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately a second pass rather than part of {@link readSourcePages}: it
|
|
48
|
+
* spawns a git process per page, and only the content index has a date field.
|
|
49
|
+
* Folding it into the read would make the search and llms indexes pay for a
|
|
50
|
+
* value neither of them has anywhere to put. For the same reason it skips
|
|
51
|
+
* untitled pages, which no index carries.
|
|
52
|
+
*/
|
|
53
|
+
export declare function withCommitDates(pages: SourcePage[]): SourcePage[];
|
|
3
54
|
/**
|
|
4
55
|
* List the absolute paths of every `+page.md`/`+page.svx` under `contentDir`.
|
|
5
56
|
*/
|
|
@@ -1,10 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The docs root, and what a page is before any index has looked at it. Reading
|
|
3
|
+
* stops at a {@link SourcePage} list, which is the only value the index builders
|
|
4
|
+
* in `./collect.js` see — so the same builders run against a real docs root in
|
|
5
|
+
* the plugin and against an array of literals in tests.
|
|
6
|
+
*/
|
|
1
7
|
import fs from 'node:fs';
|
|
2
8
|
import path from 'node:path';
|
|
9
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
10
|
+
import { lastCommitDate } from '../git.js';
|
|
3
11
|
const PAGE_NAMES = ['+page.md', '+page.svx'];
|
|
4
12
|
/** Whether a filename is a doc page the plugin should index. */
|
|
5
13
|
export function isPageFile(file) {
|
|
6
14
|
return PAGE_NAMES.some((name) => file.endsWith(name));
|
|
7
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Turn a page's path and raw markdown into a {@link SourcePage}. Pure, and
|
|
18
|
+
* shared with tests so an in-memory page is built exactly the way a read one is.
|
|
19
|
+
*/
|
|
20
|
+
export function toPage(file, source) {
|
|
21
|
+
return { file, source, frontmatter: parseFrontmatter(source, file) };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read every doc page under `contentDir`. `exists` distinguishes a missing docs
|
|
25
|
+
* root from an empty one, which the page list alone cannot: both give no pages,
|
|
26
|
+
* but only one of them is a misconfiguration worth reporting.
|
|
27
|
+
*/
|
|
28
|
+
export function readSourcePages(contentDir) {
|
|
29
|
+
if (!fs.existsSync(contentDir))
|
|
30
|
+
return { pages: [], exists: false };
|
|
31
|
+
const pages = listPageFiles(contentDir).map((file) => toPage(file, fs.readFileSync(file, 'utf-8')));
|
|
32
|
+
return { pages, exists: true };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A page's title, when it has one. A page whose frontmatter has no string
|
|
36
|
+
* `title` is a stub: no index carries it, so nothing is derived for it and
|
|
37
|
+
* nothing is looked up about it.
|
|
38
|
+
*/
|
|
39
|
+
export function titleOf(page) {
|
|
40
|
+
return typeof page.frontmatter.title === 'string' ? page.frontmatter.title : undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Date each page from the commit that last touched it, skipping the lookup for
|
|
44
|
+
* pages that carry their own `lastUpdated`. Archiving copies every page in one
|
|
45
|
+
* commit, so an archive's pages are written with the date they last really
|
|
46
|
+
* changed and must keep it.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately a second pass rather than part of {@link readSourcePages}: it
|
|
49
|
+
* spawns a git process per page, and only the content index has a date field.
|
|
50
|
+
* Folding it into the read would make the search and llms indexes pay for a
|
|
51
|
+
* value neither of them has anywhere to put. For the same reason it skips
|
|
52
|
+
* untitled pages, which no index carries.
|
|
53
|
+
*/
|
|
54
|
+
export function withCommitDates(pages) {
|
|
55
|
+
return pages.map((page) => {
|
|
56
|
+
if (titleOf(page) === undefined)
|
|
57
|
+
return page;
|
|
58
|
+
return {
|
|
59
|
+
...page,
|
|
60
|
+
lastUpdated: typeof page.frontmatter.lastUpdated === 'string'
|
|
61
|
+
? page.frontmatter.lastUpdated
|
|
62
|
+
: lastCommitDate(page.file)
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
8
66
|
/**
|
|
9
67
|
* List the absolute paths of every `+page.md`/`+page.svx` under `contentDir`.
|
|
10
68
|
*/
|
|
@@ -8,5 +8,3 @@ import type { ChangelogRelease } from '../../core/changelog.js';
|
|
|
8
8
|
* have one while the rest stay generated.
|
|
9
9
|
*/
|
|
10
10
|
export declare function collectReleases(changelogFile: string, overridesDir?: string, routePath?: string): ChangelogRelease[];
|
|
11
|
-
/** Default location of a monorepo package's changelog, relative to the app. */
|
|
12
|
-
export declare function defaultChangelogPath(cwd?: string): string;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import { marked } from 'marked';
|
|
4
3
|
import { parseChangelog } from '../changelog.js';
|
|
5
4
|
import { changelogDates } from '../git.js';
|
|
@@ -51,7 +50,3 @@ export function collectReleases(changelogFile, overridesDir, routePath = '/chang
|
|
|
51
50
|
};
|
|
52
51
|
});
|
|
53
52
|
}
|
|
54
|
-
/** Default location of a monorepo package's changelog, relative to the app. */
|
|
55
|
-
export function defaultChangelogPath(cwd = process.cwd()) {
|
|
56
|
-
return path.resolve(cwd, 'CHANGELOG.md');
|
|
57
|
-
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import Copy from '@lucide/svelte/icons/copy';
|
|
8
8
|
import FileText from '@lucide/svelte/icons/file-text';
|
|
9
9
|
import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right';
|
|
10
|
+
import { join } from '../../utils/url.js';
|
|
10
11
|
|
|
11
12
|
const {
|
|
12
13
|
path,
|
|
@@ -21,8 +22,9 @@
|
|
|
21
22
|
// Convention: each page's markdown lives at `<path>.md`, served by the
|
|
22
23
|
// consumer's catch-all endpoint over the `svelte-docsmith/llms` index.
|
|
23
24
|
const mdHref = $derived(`${path}.md`);
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
// Before hydration with no configured origin there is nothing to prefix, and
|
|
26
|
+
// `join` leaves the link site-relative rather than mangling it.
|
|
27
|
+
const absoluteMd = $derived(join(origin || (browser ? location.origin : ''), mdHref));
|
|
26
28
|
const prompt = $derived(`Read ${absoluteMd} so I can ask questions about this page.`);
|
|
27
29
|
const chatgptHref = $derived(`https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt)}`);
|
|
28
30
|
const claudeHref = $derived(`https://claude.ai/new?q=${encodeURIComponent(prompt)}`);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import BookOpenText from '@lucide/svelte/icons/book-open-text';
|
|
12
12
|
import type { Snippet } from 'svelte';
|
|
13
13
|
import { cn } from '../../utils/cn.js';
|
|
14
|
-
import { normalizePath } from '../../utils/
|
|
14
|
+
import { firstSegmentUnder, join, normalizePath, under } from '../../utils/url.js';
|
|
15
15
|
import { useDocsPage } from '../docs-page-context.js';
|
|
16
16
|
|
|
17
17
|
const {
|
|
@@ -42,11 +42,15 @@
|
|
|
42
42
|
*/
|
|
43
43
|
function isActive(link: DocsmithLink): boolean {
|
|
44
44
|
if (link.external) return false;
|
|
45
|
-
|
|
45
|
+
// `href` is only required to be a string, so anchor it at the site root:
|
|
46
|
+
// `docs/intro` has to light the same section as `/docs/intro`.
|
|
47
|
+
const href = normalizePath(join('/', link.href));
|
|
46
48
|
if (href === current) return true;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
// No first segment means the link is the root, or is not a site path at
|
|
50
|
+
// all (an absolute URL left unflagged), and owns no section either way.
|
|
51
|
+
const segment = firstSegmentUnder(href, '/');
|
|
52
|
+
if (!segment) return false;
|
|
53
|
+
return under(current, '/' + segment);
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
let isScrolled = $state(false);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { page } from '$app/state';
|
|
3
3
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
4
4
|
import { cn } from '../../utils/cn.js';
|
|
5
|
-
import { normalizePath } from '../../utils/
|
|
5
|
+
import { normalizePath } from '../../utils/url.js';
|
|
6
6
|
import { isNavGroup, type NavGroup, type NavNode } from '../../core/index.js';
|
|
7
7
|
|
|
8
8
|
const { nav, class: className = '' }: { nav: NavGroup[]; class?: string } = $props();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { page } from '$app/state';
|
|
3
|
-
import { normalizePath } from '../../utils/
|
|
3
|
+
import { join, normalizePath } from '../../utils/url.js';
|
|
4
4
|
import type { DocsmithConfig } from '../../core/index.js';
|
|
5
5
|
|
|
6
6
|
const {
|
|
@@ -25,14 +25,16 @@
|
|
|
25
25
|
const ogTitle = $derived(title ?? config.title);
|
|
26
26
|
const metaDescription = $derived(description ?? config.description);
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
const canonical = $derived(
|
|
28
|
+
// Absolute URLs need a configured origin; otherwise canonical/og:url are
|
|
29
|
+
// omitted rather than emitted relative, which `join` alone would do.
|
|
30
|
+
const canonical = $derived(
|
|
31
|
+
config.url ? join(config.url, normalizePath(page.url.pathname)) : undefined
|
|
32
|
+
);
|
|
31
33
|
const image = $derived.by(() => {
|
|
32
34
|
const src = config.ogImage;
|
|
33
35
|
if (!src) return undefined;
|
|
34
36
|
if (/^https?:\/\//.test(src)) return src;
|
|
35
|
-
return
|
|
37
|
+
return config.url ? join(config.url, src) : src;
|
|
36
38
|
});
|
|
37
39
|
</script>
|
|
38
40
|
|
package/dist/core/changelog.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ export type ChangelogRelease = {
|
|
|
29
29
|
* A hand-written page for this release, when `src/routes/changelog/<version>/`
|
|
30
30
|
* exists. The generated entry is the fallback, so a release that deserves a
|
|
31
31
|
* proper write-up can have one without the rest going undocumented.
|
|
32
|
+
*
|
|
33
|
+
* Joined to the origin for the feed with exactly one slash between, so the
|
|
34
|
+
* leading slash is optional.
|
|
32
35
|
*/
|
|
33
36
|
path?: string;
|
|
34
37
|
};
|
package/dist/core/docs-page.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { navFromContent, flattenNav, navTrail } from './nav.js';
|
|
2
2
|
import { activeVersion, currentVersion, mapPathToVersion, scopeContent } from './version.js';
|
|
3
|
-
import { normalizePath } from '../utils/
|
|
3
|
+
import { join, normalizePath } from '../utils/url.js';
|
|
4
4
|
/**
|
|
5
5
|
* Resolve the page being read. `pathname` is taken raw (normalized here, so the
|
|
6
6
|
* caller has one fewer place to remember) and every field is derived from the
|
|
@@ -34,9 +34,7 @@ export function resolveDocsPage(input) {
|
|
|
34
34
|
scopedContent,
|
|
35
35
|
nav,
|
|
36
36
|
entry,
|
|
37
|
-
editHref: editUrl && entry?.sourcePath && !isArchived
|
|
38
|
-
? editUrl.replace(/\/$/, '') + '/' + entry.sourcePath
|
|
39
|
-
: undefined,
|
|
37
|
+
editHref: editUrl && entry?.sourcePath && !isArchived ? join(editUrl, entry.sourcePath) : undefined,
|
|
40
38
|
lastUpdated: parseDate(entry?.lastUpdated),
|
|
41
39
|
readingMinutes: entry?.readingTime,
|
|
42
40
|
prev: pageIndex > 0 ? flatNav[pageIndex - 1] : undefined,
|
package/dist/core/version.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { navFromContent, flattenNav } from './nav.js';
|
|
2
|
-
import { normalizePath } from '../utils/
|
|
3
|
-
/** True when `pathname` is `base` or sits beneath it on a segment boundary. */
|
|
4
|
-
function underBase(pathname, base) {
|
|
5
|
-
return pathname === base || pathname.startsWith(base + '/');
|
|
6
|
-
}
|
|
2
|
+
import { join, normalizePath, under } from '../utils/url.js';
|
|
7
3
|
/**
|
|
8
4
|
* Legal version id. Must start alphanumeric: an archived id is both a directory
|
|
9
5
|
* name and a URL segment, so `.`, `..` and dotfiles would escape the docs root
|
|
@@ -130,7 +126,7 @@ export function resolveVersions(versions, docsBase, content) {
|
|
|
130
126
|
};
|
|
131
127
|
return [
|
|
132
128
|
resolve(versions.current, base, true),
|
|
133
|
-
...(versions.archived ?? []).map((version) => resolve(version,
|
|
129
|
+
...(versions.archived ?? []).map((version) => resolve(version, join(base, version.id), false))
|
|
134
130
|
];
|
|
135
131
|
}
|
|
136
132
|
/**
|
|
@@ -143,7 +139,7 @@ export function activeVersion(versions, pathname) {
|
|
|
143
139
|
const path = normalizePath(pathname);
|
|
144
140
|
let best;
|
|
145
141
|
for (const v of versions) {
|
|
146
|
-
if (
|
|
142
|
+
if (under(path, v.basePath) && (!best || v.basePath.length > best.basePath.length)) {
|
|
147
143
|
best = v;
|
|
148
144
|
}
|
|
149
145
|
}
|
|
@@ -179,8 +175,8 @@ export function scopeContent(content, versionId) {
|
|
|
179
175
|
*/
|
|
180
176
|
export function mapPathToVersion(pathname, from, to, targetPaths) {
|
|
181
177
|
const path = normalizePath(pathname);
|
|
182
|
-
const rel =
|
|
183
|
-
const candidate =
|
|
178
|
+
const rel = under(path, from.basePath) ? path.slice(from.basePath.length) : '';
|
|
179
|
+
const candidate = join(to.basePath, rel);
|
|
184
180
|
const set = targetPaths instanceof Set ? targetPaths : new Set(targetPaths);
|
|
185
181
|
return set.has(candidate) ? candidate : to.landing;
|
|
186
182
|
}
|
package/dist/generate/feed.d.ts
CHANGED
|
@@ -7,7 +7,10 @@ export type FeedSite = {
|
|
|
7
7
|
title: string;
|
|
8
8
|
/** One-line feed description. */
|
|
9
9
|
description?: string;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Path the changelog lives at. Default: `/changelog`. Joined to the origin with
|
|
12
|
+
* exactly one slash between, so the leading slash is optional.
|
|
13
|
+
*/
|
|
11
14
|
path?: string;
|
|
12
15
|
};
|
|
13
16
|
/**
|
package/dist/generate/feed.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
case '&':
|
|
4
|
-
return '&';
|
|
5
|
-
case '<':
|
|
6
|
-
return '<';
|
|
7
|
-
case '>':
|
|
8
|
-
return '>';
|
|
9
|
-
case "'":
|
|
10
|
-
return ''';
|
|
11
|
-
default:
|
|
12
|
-
return '"';
|
|
13
|
-
}
|
|
14
|
-
});
|
|
1
|
+
import { join } from '../utils/url.js';
|
|
2
|
+
import { escapeXml } from './xml.js';
|
|
15
3
|
/** A release's entries flattened to plain text, grouped by kind. */
|
|
16
4
|
function summarise(release) {
|
|
17
5
|
return release.groups
|
|
@@ -41,17 +29,17 @@ function summarise(release) {
|
|
|
41
29
|
* update timestamp, which is exactly what a version and its release date give.
|
|
42
30
|
*/
|
|
43
31
|
export function generateFeed(releases, site) {
|
|
44
|
-
const origin = site.url.replace(/\/$/, '');
|
|
45
32
|
const path = site.path ?? '/changelog';
|
|
46
|
-
const
|
|
33
|
+
const changelogUrl = join(site.url, path);
|
|
34
|
+
const feedUrl = `${changelogUrl}.xml`;
|
|
47
35
|
// A feed needs an updated timestamp; fall back to now when no release
|
|
48
36
|
// carries a date, so the document stays valid.
|
|
49
37
|
const updated = releases.find((r) => r.date)?.date ?? new Date().toISOString();
|
|
50
38
|
const entries = releases
|
|
51
39
|
.map((release) => {
|
|
52
40
|
const link = release.path
|
|
53
|
-
?
|
|
54
|
-
: `${
|
|
41
|
+
? join(site.url, release.path)
|
|
42
|
+
: `${changelogUrl}#${release.version}`;
|
|
55
43
|
return [
|
|
56
44
|
'\t<entry>',
|
|
57
45
|
`\t\t<title>${escapeXml(release.version)}</title>`,
|
|
@@ -69,7 +57,7 @@ export function generateFeed(releases, site) {
|
|
|
69
57
|
`\t<title>${escapeXml(site.title)}</title>`,
|
|
70
58
|
site.description ? `\t<subtitle>${escapeXml(site.description)}</subtitle>` : '',
|
|
71
59
|
`\t<id>${escapeXml(feedUrl)}</id>`,
|
|
72
|
-
`\t<link href="${escapeXml(
|
|
60
|
+
`\t<link href="${escapeXml(changelogUrl)}" />`,
|
|
73
61
|
`\t<link rel="self" href="${escapeXml(feedUrl)}" />`,
|
|
74
62
|
`\t<updated>${escapeXml(updated)}</updated>`,
|
|
75
63
|
entries,
|
package/dist/generate/llms.d.ts
CHANGED
|
@@ -3,7 +3,10 @@ import type { LlmsDoc } from '../core/content.js';
|
|
|
3
3
|
export type LlmsSite = {
|
|
4
4
|
title: string;
|
|
5
5
|
description?: string;
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Absolute origin, e.g. `https://my-docs.dev`, used to make links absolute.
|
|
8
|
+
* Without it the links stay site-relative.
|
|
9
|
+
*/
|
|
7
10
|
origin?: string;
|
|
8
11
|
};
|
|
9
12
|
/**
|
package/dist/generate/llms.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from '../utils/url.js';
|
|
1
2
|
/**
|
|
2
3
|
* Group docs by section and put both the sections and the docs within them in
|
|
3
4
|
* sidebar reading order: items by their `order`, sections by the smallest
|
|
@@ -38,7 +39,7 @@ function bySection(docs) {
|
|
|
38
39
|
* ```
|
|
39
40
|
*/
|
|
40
41
|
export function generateLlmsTxt(site, docs) {
|
|
41
|
-
const
|
|
42
|
+
const origin = site.origin ?? '';
|
|
42
43
|
const out = [`# ${site.title}`];
|
|
43
44
|
if (site.description)
|
|
44
45
|
out.push('', `> ${site.description}`);
|
|
@@ -46,7 +47,7 @@ export function generateLlmsTxt(site, docs) {
|
|
|
46
47
|
out.push('', `## ${section}`, '');
|
|
47
48
|
for (const doc of items) {
|
|
48
49
|
const suffix = doc.description ? `: ${doc.description}` : '';
|
|
49
|
-
out.push(`- [${doc.title}](${
|
|
50
|
+
out.push(`- [${doc.title}](${join(origin, doc.path)})${suffix}`);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
return out.join('\n') + '\n';
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** One entry in a sitemap: a page path (joined to the origin) and optional last-modified date. */
|
|
2
2
|
export type SitemapEntry = {
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Page path, e.g. `/docs/intro`. Joined to the origin with exactly one slash
|
|
5
|
+
* between, so the leading slash is optional.
|
|
6
|
+
*/
|
|
4
7
|
path: string;
|
|
5
8
|
/** Last-modified date; an ISO string or `YYYY-MM-DD`. Only the date is emitted. */
|
|
6
9
|
lastmod?: string;
|
package/dist/generate/sitemap.js
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
case '&':
|
|
4
|
-
return '&';
|
|
5
|
-
case '<':
|
|
6
|
-
return '<';
|
|
7
|
-
case '>':
|
|
8
|
-
return '>';
|
|
9
|
-
case "'":
|
|
10
|
-
return ''';
|
|
11
|
-
default:
|
|
12
|
-
return '"';
|
|
13
|
-
}
|
|
14
|
-
});
|
|
1
|
+
import { join } from '../utils/url.js';
|
|
2
|
+
import { escapeXml } from './xml.js';
|
|
15
3
|
/**
|
|
16
4
|
* Build a sitemap.xml body from a list of pages. Framework-agnostic: wire it
|
|
17
5
|
* into a SvelteKit `src/routes/sitemap.xml/+server.ts` that maps the generated
|
|
@@ -32,10 +20,9 @@ const escapeXml = (s) => s.replace(/[&<>'"]/g, (c) => {
|
|
|
32
20
|
* ```
|
|
33
21
|
*/
|
|
34
22
|
export function generateSitemap(origin, entries) {
|
|
35
|
-
const base = origin.replace(/\/$/, '');
|
|
36
23
|
const urls = entries
|
|
37
24
|
.map(({ path, lastmod }) => {
|
|
38
|
-
const loc = `\t\t<loc>${escapeXml(
|
|
25
|
+
const loc = `\t\t<loc>${escapeXml(join(origin, path))}</loc>`;
|
|
39
26
|
const mod = lastmod ? `\n\t\t<lastmod>${lastmod.slice(0, 10)}</lastmod>` : '';
|
|
40
27
|
return `\t<url>\n${loc}${mod}\n\t</url>`;
|
|
41
28
|
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared escaping for the XML documents this layer emits. `sitemap.xml` and the
|
|
3
|
+
* changelog's Atom feed both interpolate site-supplied strings into markup, and
|
|
4
|
+
* both need the same five characters escaped, so the rule lives here rather than
|
|
5
|
+
* once per generator.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Escape a string for use as XML text or as an attribute value. All five
|
|
9
|
+
* predefined entities are escaped, rather than the subset each position strictly
|
|
10
|
+
* needs, so a caller can never pick the wrong one.
|
|
11
|
+
*/
|
|
12
|
+
export declare function escapeXml(s: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared escaping for the XML documents this layer emits. `sitemap.xml` and the
|
|
3
|
+
* changelog's Atom feed both interpolate site-supplied strings into markup, and
|
|
4
|
+
* both need the same five characters escaped, so the rule lives here rather than
|
|
5
|
+
* once per generator.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Escape a string for use as XML text or as an attribute value. All five
|
|
9
|
+
* predefined entities are escaped, rather than the subset each position strictly
|
|
10
|
+
* needs, so a caller can never pick the wrong one.
|
|
11
|
+
*/
|
|
12
|
+
export function escapeXml(s) {
|
|
13
|
+
return s.replace(/[&<>'"]/g, (c) => {
|
|
14
|
+
switch (c) {
|
|
15
|
+
case '&':
|
|
16
|
+
return '&';
|
|
17
|
+
case '<':
|
|
18
|
+
return '<';
|
|
19
|
+
case '>':
|
|
20
|
+
return '>';
|
|
21
|
+
case "'":
|
|
22
|
+
return ''';
|
|
23
|
+
default:
|
|
24
|
+
return '"';
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary for doc URLs: normalize one for matching, join two to build
|
|
3
|
+
* one, and ask whether one sits under a base. Every layer that touches URL
|
|
4
|
+
* space goes through here, because the alternative is what this replaced: the
|
|
5
|
+
* same trailing-slash strip written with three different regexes and the same
|
|
6
|
+
* segment-boundary rule written three times, quietly disagreeing.
|
|
7
|
+
*
|
|
8
|
+
* Pure string work over a URL's path space. Nothing here knows about docs,
|
|
9
|
+
* versions or pages; the domain meaning lives in `core/`, which calls in.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Strip a trailing slash so route matching is consistent regardless of the
|
|
13
|
+
* consumer's SvelteKit `trailingSlash` setting: `/docs/intro/` and `/docs/intro`
|
|
14
|
+
* resolve to the same content entry. The root stays `/`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizePath(pathname: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* True when nothing follows a base, or what follows starts a new segment rather
|
|
19
|
+
* than continuing the last one. `?` and `#` count, so the rule holds for a link
|
|
20
|
+
* href as well as a pathname; that is what stops `/docs` from matching
|
|
21
|
+
* `/docsmith`.
|
|
22
|
+
*
|
|
23
|
+
* Exported for the one caller that has already sliced the base off itself: the
|
|
24
|
+
* archive link rewriter matches its base with a regex and needs the remainder
|
|
25
|
+
* afterwards, so it checks the boundary rather than re-testing the whole URL.
|
|
26
|
+
*/
|
|
27
|
+
export declare function atBoundary(rest: string): boolean;
|
|
28
|
+
/** True when `url` is `base` or sits beneath it on a segment boundary. */
|
|
29
|
+
export declare function under(url: string, base: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The one segment directly below `base`, which is what decides who owns a URL:
|
|
32
|
+
* an archived version owns `<docs base>/<id>/…`, and a header link owns its
|
|
33
|
+
* whole first-segment section. `undefined` when `url` is not under `base` at
|
|
34
|
+
* all, or when the base is the end of it, so callers must handle "no segment"
|
|
35
|
+
* rather than interpolate it into a path.
|
|
36
|
+
*/
|
|
37
|
+
export declare function firstSegmentUnder(url: string, base: string): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Join two URL pieces with exactly one slash between them, whichever of them
|
|
40
|
+
* already carries it. Use it for every URL built from a configured origin or
|
|
41
|
+
* base, so a trailing slash in someone's config can never double up.
|
|
42
|
+
*
|
|
43
|
+
* Only the seam between the two is touched, never the rest of either: rescanning
|
|
44
|
+
* would turn `https://x.dev` into `https:/x.dev`. Nor is the result normalized,
|
|
45
|
+
* so `join(origin, '/')` still ends in a slash and a sitemap can carry the home
|
|
46
|
+
* page.
|
|
47
|
+
*
|
|
48
|
+
* An empty `left` returns `right` untouched, which is what an unconfigured
|
|
49
|
+
* origin needs: `llms.txt` and the copy-page menu fall back to site-relative
|
|
50
|
+
* links rather than emitting a stray leading slash. Callers that must not degrade
|
|
51
|
+
* to a relative URL, like `<link rel="canonical">`, have to check the origin
|
|
52
|
+
* themselves.
|
|
53
|
+
*/
|
|
54
|
+
export declare function join(left: string, right: string): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary for doc URLs: normalize one for matching, join two to build
|
|
3
|
+
* one, and ask whether one sits under a base. Every layer that touches URL
|
|
4
|
+
* space goes through here, because the alternative is what this replaced: the
|
|
5
|
+
* same trailing-slash strip written with three different regexes and the same
|
|
6
|
+
* segment-boundary rule written three times, quietly disagreeing.
|
|
7
|
+
*
|
|
8
|
+
* Pure string work over a URL's path space. Nothing here knows about docs,
|
|
9
|
+
* versions or pages; the domain meaning lives in `core/`, which calls in.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Strip a trailing slash so route matching is consistent regardless of the
|
|
13
|
+
* consumer's SvelteKit `trailingSlash` setting: `/docs/intro/` and `/docs/intro`
|
|
14
|
+
* resolve to the same content entry. The root stays `/`.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizePath(pathname) {
|
|
17
|
+
return pathname.replace(/\/+$/, '') || '/';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A base as a string prefix. The site root addresses everything, so it has no
|
|
21
|
+
* prefix: spelled `/`, the boundary check below would look for `//`. `''` and
|
|
22
|
+
* `/` are therefore the same base, and callers may pass whichever they hold.
|
|
23
|
+
* Reachable through `docsBaseFrom` when a site puts its docs at the routes root.
|
|
24
|
+
*/
|
|
25
|
+
function baseAsPrefix(base) {
|
|
26
|
+
return base === '/' ? '' : base;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* True when nothing follows a base, or what follows starts a new segment rather
|
|
30
|
+
* than continuing the last one. `?` and `#` count, so the rule holds for a link
|
|
31
|
+
* href as well as a pathname; that is what stops `/docs` from matching
|
|
32
|
+
* `/docsmith`.
|
|
33
|
+
*
|
|
34
|
+
* Exported for the one caller that has already sliced the base off itself: the
|
|
35
|
+
* archive link rewriter matches its base with a regex and needs the remainder
|
|
36
|
+
* afterwards, so it checks the boundary rather than re-testing the whole URL.
|
|
37
|
+
*/
|
|
38
|
+
export function atBoundary(rest) {
|
|
39
|
+
return rest === '' || /^[/#?]/.test(rest);
|
|
40
|
+
}
|
|
41
|
+
/** True when `url` is `base` or sits beneath it on a segment boundary. */
|
|
42
|
+
export function under(url, base) {
|
|
43
|
+
const b = baseAsPrefix(base);
|
|
44
|
+
return url.startsWith(b) && atBoundary(url.slice(b.length));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The one segment directly below `base`, which is what decides who owns a URL:
|
|
48
|
+
* an archived version owns `<docs base>/<id>/…`, and a header link owns its
|
|
49
|
+
* whole first-segment section. `undefined` when `url` is not under `base` at
|
|
50
|
+
* all, or when the base is the end of it, so callers must handle "no segment"
|
|
51
|
+
* rather than interpolate it into a path.
|
|
52
|
+
*/
|
|
53
|
+
export function firstSegmentUnder(url, base) {
|
|
54
|
+
if (!under(url, base))
|
|
55
|
+
return undefined;
|
|
56
|
+
const rest = url.slice(baseAsPrefix(base).length);
|
|
57
|
+
if (!rest.startsWith('/'))
|
|
58
|
+
return undefined;
|
|
59
|
+
return rest.slice(1).split(/[/#?]/)[0] || undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Join two URL pieces with exactly one slash between them, whichever of them
|
|
63
|
+
* already carries it. Use it for every URL built from a configured origin or
|
|
64
|
+
* base, so a trailing slash in someone's config can never double up.
|
|
65
|
+
*
|
|
66
|
+
* Only the seam between the two is touched, never the rest of either: rescanning
|
|
67
|
+
* would turn `https://x.dev` into `https:/x.dev`. Nor is the result normalized,
|
|
68
|
+
* so `join(origin, '/')` still ends in a slash and a sitemap can carry the home
|
|
69
|
+
* page.
|
|
70
|
+
*
|
|
71
|
+
* An empty `left` returns `right` untouched, which is what an unconfigured
|
|
72
|
+
* origin needs: `llms.txt` and the copy-page menu fall back to site-relative
|
|
73
|
+
* links rather than emitting a stray leading slash. Callers that must not degrade
|
|
74
|
+
* to a relative URL, like `<link rel="canonical">`, have to check the origin
|
|
75
|
+
* themselves.
|
|
76
|
+
*/
|
|
77
|
+
export function join(left, right) {
|
|
78
|
+
if (!left)
|
|
79
|
+
return right;
|
|
80
|
+
const l = left.replace(/\/+$/, '');
|
|
81
|
+
if (!right)
|
|
82
|
+
return l || '/';
|
|
83
|
+
return l + '/' + right.replace(/^\/+/, '');
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Strip a trailing slash so route matching is consistent regardless of the
|
|
3
|
-
* consumer's SvelteKit `trailingSlash` setting: `/docs/intro/` and `/docs/intro`
|
|
4
|
-
* resolve to the same content entry. The root stays `/`.
|
|
5
|
-
*/
|
|
6
|
-
export declare function normalizePath(pathname: string): string;
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Strip a trailing slash so route matching is consistent regardless of the
|
|
3
|
-
* consumer's SvelteKit `trailingSlash` setting: `/docs/intro/` and `/docs/intro`
|
|
4
|
-
* resolve to the same content entry. The root stays `/`.
|
|
5
|
-
*/
|
|
6
|
-
export function normalizePath(pathname) {
|
|
7
|
-
return pathname.replace(/\/+$/, '') || '/';
|
|
8
|
-
}
|