@classic-homes/theme-docs 0.1.0 → 0.2.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/lib/components/Breadcrumbs.svelte +55 -0
- package/dist/lib/components/Breadcrumbs.svelte.d.ts +14 -0
- package/dist/lib/components/CategoryIndex.svelte +51 -0
- package/dist/lib/components/CategoryIndex.svelte.d.ts +13 -0
- package/dist/lib/components/DocPage.svelte +172 -0
- package/dist/lib/components/DocPage.svelte.d.ts +60 -0
- package/dist/lib/components/DocPager.svelte +49 -0
- package/dist/lib/components/DocPager.svelte.d.ts +12 -0
- package/dist/lib/components/MarkdownPage.svelte +3 -1
- package/dist/lib/components/MermaidDiagram.svelte +2 -0
- package/dist/lib/components/MermaidInit.svelte +2 -0
- package/dist/lib/components/TableOfContents.svelte +114 -125
- package/dist/lib/components/TableOfContents.svelte.d.ts +11 -4
- package/dist/lib/components/TagIndex.svelte +42 -0
- package/dist/lib/components/TagIndex.svelte.d.ts +14 -0
- package/dist/lib/components/TagList.svelte +45 -0
- package/dist/lib/components/TagList.svelte.d.ts +15 -0
- package/dist/lib/components/TocPanel.svelte +27 -9
- package/dist/lib/components/TocPanel.svelte.d.ts +10 -3
- package/dist/lib/components/enhance.d.ts +29 -0
- package/dist/lib/components/enhance.js +179 -0
- package/dist/lib/components/sidebar.d.ts +33 -0
- package/dist/lib/components/sidebar.js +84 -0
- package/dist/lib/content/browser.d.ts +6 -0
- package/dist/lib/content/browser.js +5 -0
- package/dist/lib/content/index.d.ts +13 -0
- package/dist/lib/content/index.js +12 -0
- package/dist/lib/content/load.d.ts +77 -0
- package/dist/lib/content/load.js +366 -0
- package/dist/lib/content/nav.d.ts +36 -0
- package/dist/lib/content/nav.js +81 -0
- package/dist/lib/content/render.d.ts +38 -0
- package/dist/lib/content/render.js +84 -0
- package/dist/lib/content/types.d.ts +90 -0
- package/dist/lib/content/types.js +5 -0
- package/dist/lib/index.d.ts +14 -2
- package/dist/lib/index.js +14 -2
- package/dist/lib/parser/api.d.ts +12 -0
- package/dist/lib/parser/api.js +10 -0
- package/dist/lib/parser/extensions.d.ts +27 -15
- package/dist/lib/parser/extensions.js +58 -53
- package/dist/lib/parser/index.d.ts +4 -1
- package/dist/lib/parser/index.js +104 -27
- package/dist/lib/sanitize/index.d.ts +11 -0
- package/dist/lib/sanitize/index.js +122 -0
- package/dist/lib/search/index.d.ts +57 -0
- package/dist/lib/search/index.js +82 -0
- package/dist/lib/styles/markdown.css +138 -0
- package/dist/lib/types/frontmatter.d.ts +21 -0
- package/dist/lib/vite/index.d.ts +17 -0
- package/dist/lib/vite/index.js +38 -0
- package/package.json +51 -4
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a directory of markdown into the docs content model, the way Docusaurus does:
|
|
3
|
+
* routes from file paths, autogenerated sidebars ordered by `sidebar_position` and
|
|
4
|
+
* `_category_.json`, generated-index category pages, tags, and relative `.md` links
|
|
5
|
+
* rewritten to routes. Node-only (reads the file system).
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import yaml from 'js-yaml';
|
|
10
|
+
import { createSlugger } from '../parser/slug.js';
|
|
11
|
+
const isDoc = (name) => /\.mdx?$/.test(name);
|
|
12
|
+
const isIndexDoc = (name) => /^index\.mdx?$/.test(name);
|
|
13
|
+
/** Docusaurus ignores `_`-prefixed files and folders (partials); category files aren't docs. */
|
|
14
|
+
const isIgnored = (name) => name.startsWith('_') || name.startsWith('.');
|
|
15
|
+
/** Directory entries in the order Docusaurus reads them: by name. */
|
|
16
|
+
function entries(dir) {
|
|
17
|
+
return fs
|
|
18
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
19
|
+
.filter((e) => !isIgnored(e.name))
|
|
20
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
21
|
+
}
|
|
22
|
+
/** Parse YAML front matter; returns `{ data, body }`. Throws on invalid YAML. */
|
|
23
|
+
export function parseFrontmatter(raw, file) {
|
|
24
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
|
|
25
|
+
if (!match)
|
|
26
|
+
return { data: {}, body: raw };
|
|
27
|
+
try {
|
|
28
|
+
return {
|
|
29
|
+
data: yaml.load(match[1]) || {},
|
|
30
|
+
body: raw.slice(match[0].length),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new Error(`${file}: invalid front matter: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** `it/network/vpn/index.md` → `/docs/it/network/vpn`. */
|
|
38
|
+
export function routeOf(file, routeBase = '/docs') {
|
|
39
|
+
let stem = file.replace(/\.mdx?$/, '');
|
|
40
|
+
if (path.posix.basename(stem) === 'index')
|
|
41
|
+
stem = path.posix.dirname(stem);
|
|
42
|
+
return stem && stem !== '.' ? `${routeBase}/${stem}` : routeBase;
|
|
43
|
+
}
|
|
44
|
+
/** `_category_.json` or `_category_.yml` in a folder, or `{}` when it has neither. */
|
|
45
|
+
function readCategory(dirAbs) {
|
|
46
|
+
for (const name of ['_category_.json', '_category_.yml', '_category_.yaml']) {
|
|
47
|
+
const file = path.join(dirAbs, name);
|
|
48
|
+
if (!fs.existsSync(file))
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
return yaml.load(fs.readFileSync(file, 'utf8')) ?? {};
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new Error(`${file} is not valid ${name.endsWith('.json') ? 'JSON' : 'YAML'}: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read a docs directory into pages, sidebars, generated-index pages and tags.
|
|
61
|
+
*
|
|
62
|
+
* Routes come from file paths; `slug` and `id` front matter are rejected rather than
|
|
63
|
+
* silently ignored. Relative links to `.md`/`.mdx` files become routes (anchors kept),
|
|
64
|
+
* Docusaurus' `pathname://` prefix is unwrapped, and links under `routeBase` are checked.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* const docs = loadDocs({ dir: 'docs', sidebarRoots: ['guides', 'reference'] });
|
|
69
|
+
* const { pages } = await renderDocs(docs, { parse: { headingIdStyle: 'github' } });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function loadDocs(options) {
|
|
73
|
+
const { dir, routeBase: contentRouteBase = '/docs', baseUrl: rawBaseUrl = '', sidebarRoots = [''], tags: vocabulary, onBrokenLinks = 'throw', includeDrafts = false, validate, } = options;
|
|
74
|
+
const warnings = [];
|
|
75
|
+
const baseUrl = rawBaseUrl.replace(/\/+$/, '');
|
|
76
|
+
// Routes as served: under the base URL
|
|
77
|
+
const routeBase = `${baseUrl}${contentRouteBase}`;
|
|
78
|
+
const roots = [...new Set(sidebarRoots.map((r) => r.replace(/^\/+|\/+$/g, '')))];
|
|
79
|
+
// Pass 1: every page, keyed by its path under the docs dir.
|
|
80
|
+
const pagesByFile = new Map();
|
|
81
|
+
(function walk(dirAbs) {
|
|
82
|
+
for (const entry of entries(dirAbs)) {
|
|
83
|
+
const full = path.join(dirAbs, entry.name);
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
walk(full);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!isDoc(entry.name))
|
|
89
|
+
continue;
|
|
90
|
+
const file = path.relative(dir, full).split(path.sep).join('/');
|
|
91
|
+
const { data, body } = parseFrontmatter(fs.readFileSync(full, 'utf8'), file);
|
|
92
|
+
if (data.draft === true && !includeDrafts)
|
|
93
|
+
continue;
|
|
94
|
+
if (data.slug !== undefined || data.id !== undefined) {
|
|
95
|
+
throw new Error(`${file}: "slug" and "id" front matter are not supported; the route is the file path`);
|
|
96
|
+
}
|
|
97
|
+
const route = routeOf(file, routeBase);
|
|
98
|
+
const h1 = /^#\s+(.+)$/m.exec(body);
|
|
99
|
+
pagesByFile.set(file, {
|
|
100
|
+
route,
|
|
101
|
+
file,
|
|
102
|
+
sidebar: sidebarOf(file),
|
|
103
|
+
title: typeof data.title === 'string'
|
|
104
|
+
? data.title
|
|
105
|
+
: h1
|
|
106
|
+
? h1[1].trim()
|
|
107
|
+
: path.posix.basename(route),
|
|
108
|
+
sidebarLabel: typeof data.sidebar_label === 'string' ? data.sidebar_label : undefined,
|
|
109
|
+
description: typeof data.description === 'string' ? data.description : '',
|
|
110
|
+
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
|
|
111
|
+
position: typeof data.sidebar_position === 'number' ? data.sidebar_position : undefined,
|
|
112
|
+
frontmatter: data,
|
|
113
|
+
body,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
})(dir);
|
|
117
|
+
function sidebarOf(file) {
|
|
118
|
+
const root = roots
|
|
119
|
+
.filter((r) => r === '' || file.startsWith(`${r}/`))
|
|
120
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
121
|
+
return root === undefined ? null : sidebarId(root);
|
|
122
|
+
}
|
|
123
|
+
const pageByRoute = new Map();
|
|
124
|
+
for (const page of pagesByFile.values()) {
|
|
125
|
+
const clash = pageByRoute.get(page.route);
|
|
126
|
+
if (clash)
|
|
127
|
+
throw new Error(`duplicate route ${page.route}: ${clash.file} and ${page.file}`);
|
|
128
|
+
pageByRoute.set(page.route, page);
|
|
129
|
+
}
|
|
130
|
+
// Tags: closed vocabulary when given, as Docusaurus' onInlineTags: 'throw' enforces.
|
|
131
|
+
let tags;
|
|
132
|
+
if (vocabulary) {
|
|
133
|
+
for (const page of pagesByFile.values()) {
|
|
134
|
+
const unknown = page.tags.filter((tag) => !(tag in vocabulary));
|
|
135
|
+
if (unknown.length)
|
|
136
|
+
throw new Error(`${page.file}: unknown tag(s) ${unknown.join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
tags = Object.entries(vocabulary).map(([id, value]) => ({
|
|
139
|
+
id,
|
|
140
|
+
label: value?.label ?? id,
|
|
141
|
+
description: value?.description ?? '',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const used = [...new Set([...pagesByFile.values()].flatMap((p) => p.tags))];
|
|
146
|
+
tags = used.map((id) => ({ id, label: id, description: '' }));
|
|
147
|
+
}
|
|
148
|
+
// Sidebars and generated-index pages. One slugger across the site, so two categories
|
|
149
|
+
// with the same label get distinct generated-index routes.
|
|
150
|
+
const categories = [];
|
|
151
|
+
const categorySlug = createSlugger('github');
|
|
152
|
+
const sidebars = [];
|
|
153
|
+
for (const root of roots) {
|
|
154
|
+
const rootAbs = path.join(dir, root);
|
|
155
|
+
if (!fs.existsSync(rootAbs))
|
|
156
|
+
throw new Error(`sidebar root "${root}" does not exist under ${dir}`);
|
|
157
|
+
const meta = readCategory(rootAbs);
|
|
158
|
+
const id = sidebarId(root);
|
|
159
|
+
const own = [...pagesByFile.values()].filter((p) => p.sidebar === id);
|
|
160
|
+
if (own.length === 0)
|
|
161
|
+
continue;
|
|
162
|
+
sidebars.push({
|
|
163
|
+
id,
|
|
164
|
+
dir: root,
|
|
165
|
+
label: meta.label ?? (root ? path.posix.basename(root) : 'Docs'),
|
|
166
|
+
position: meta.position,
|
|
167
|
+
route: root ? `${routeBase}/${root}` : routeBase,
|
|
168
|
+
landingRoute: landingRouteOf(root ? `${routeBase}/${root}` : routeBase, own),
|
|
169
|
+
items: buildNav(root, id, roots),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
sidebars.sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity) || a.label.localeCompare(b.label));
|
|
173
|
+
/**
|
|
174
|
+
* One sidebar, built the way Docusaurus autogenerates it: pages and folders ordered by
|
|
175
|
+
* position (sidebar_position / _category_), unpositioned items after them in name order.
|
|
176
|
+
* A folder's index page becomes the folder's own link rather than an item inside it,
|
|
177
|
+
* unless its _category_ says otherwise. Nested sidebar roots are left to their own sidebar.
|
|
178
|
+
*/
|
|
179
|
+
function buildNav(root, sidebar, allRoots) {
|
|
180
|
+
// Docs that are some category's own link, and so not items in any list.
|
|
181
|
+
const linkedDocs = new Set();
|
|
182
|
+
function build(dirRel, isRoot) {
|
|
183
|
+
const items = [];
|
|
184
|
+
for (const entry of entries(path.join(dir, dirRel))) {
|
|
185
|
+
const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
if (allRoots.includes(rel))
|
|
188
|
+
continue; // its own sidebar
|
|
189
|
+
items.push(buildCategory(rel));
|
|
190
|
+
}
|
|
191
|
+
else if (isDoc(entry.name)) {
|
|
192
|
+
const page = pagesByFile.get(rel);
|
|
193
|
+
if (!page)
|
|
194
|
+
continue; // a draft
|
|
195
|
+
items.push({
|
|
196
|
+
type: 'page',
|
|
197
|
+
title: page.sidebarLabel ?? page.title,
|
|
198
|
+
route: page.route,
|
|
199
|
+
position: page.position,
|
|
200
|
+
file: rel,
|
|
201
|
+
isRoot,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return items;
|
|
206
|
+
}
|
|
207
|
+
function buildCategory(rel) {
|
|
208
|
+
const dirAbs = path.join(dir, rel);
|
|
209
|
+
const meta = readCategory(dirAbs);
|
|
210
|
+
const items = build(rel, false);
|
|
211
|
+
const indexFile = entries(dirAbs).find((e) => e.isFile() && isIndexDoc(e.name));
|
|
212
|
+
const label = meta.label ?? path.posix.basename(rel);
|
|
213
|
+
let route;
|
|
214
|
+
if (meta.link?.type === 'doc') {
|
|
215
|
+
const id = meta.link.id;
|
|
216
|
+
const linked = [`${id}.md`, `${id}.mdx`].find((f) => pagesByFile.has(f));
|
|
217
|
+
if (!linked)
|
|
218
|
+
throw new Error(`${rel}/_category_ links to missing doc "${id}"`);
|
|
219
|
+
linkedDocs.add(linked);
|
|
220
|
+
route = pagesByFile.get(linked).route;
|
|
221
|
+
}
|
|
222
|
+
else if (meta.link?.type === 'generated-index') {
|
|
223
|
+
route = `${routeBase}${meta.link.slug ?? `/category/${categorySlug(label)}`}`;
|
|
224
|
+
categories.push({
|
|
225
|
+
route,
|
|
226
|
+
sidebar,
|
|
227
|
+
title: meta.link.title ?? label,
|
|
228
|
+
description: meta.link.description ?? '',
|
|
229
|
+
items: [],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
else if (indexFile &&
|
|
233
|
+
meta.link === undefined &&
|
|
234
|
+
pagesByFile.has(`${rel}/${indexFile.name}`)) {
|
|
235
|
+
// By convention a folder's index page is the folder's link.
|
|
236
|
+
linkedDocs.add(`${rel}/${indexFile.name}`);
|
|
237
|
+
route = pagesByFile.get(`${rel}/${indexFile.name}`).route;
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
type: 'category',
|
|
241
|
+
label,
|
|
242
|
+
route,
|
|
243
|
+
position: meta.position,
|
|
244
|
+
...(meta.collapsible !== undefined && { collapsible: meta.collapsible }),
|
|
245
|
+
...(meta.collapsed !== undefined && { collapsed: meta.collapsed }),
|
|
246
|
+
items,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// Linked docs are only known once the whole tree is read, so drop them and sort afterwards.
|
|
250
|
+
// A category left with no items collapses as Docusaurus does: into a plain link under
|
|
251
|
+
// the category's label when it has one, or out of the sidebar when not.
|
|
252
|
+
const finish = (items) => sortItems(items.filter((item) => item.type !== 'page' || item.isRoot || !linkedDocs.has(item.file)))
|
|
253
|
+
.map((item) => {
|
|
254
|
+
if (item.type === 'page')
|
|
255
|
+
return { type: 'page', title: item.title, route: item.route };
|
|
256
|
+
const { items: drafts, ...rest } = item;
|
|
257
|
+
delete rest.position;
|
|
258
|
+
const category = rest;
|
|
259
|
+
const children = finish(drafts);
|
|
260
|
+
if (children.length > 0) {
|
|
261
|
+
// A generated-index page lists its items in sidebar order, as Docusaurus does.
|
|
262
|
+
const generated = category.route
|
|
263
|
+
? categories.find((c) => c.route === category.route && c.sidebar === sidebar)
|
|
264
|
+
: undefined;
|
|
265
|
+
if (generated)
|
|
266
|
+
generated.items = children;
|
|
267
|
+
return { ...category, items: children };
|
|
268
|
+
}
|
|
269
|
+
return category.route
|
|
270
|
+
? { type: 'page', title: category.label, route: category.route }
|
|
271
|
+
: null;
|
|
272
|
+
})
|
|
273
|
+
.filter((item) => item !== null);
|
|
274
|
+
return finish(build(root, true));
|
|
275
|
+
}
|
|
276
|
+
// Pass 2: resolve links now that every route is known.
|
|
277
|
+
const knownRoutes = new Set([...pageByRoute.keys(), ...categories.map((c) => c.route)]);
|
|
278
|
+
const broken = [];
|
|
279
|
+
const anchorLinks = [];
|
|
280
|
+
const pages = [...pagesByFile.values()].map(({ body, ...page }) => ({
|
|
281
|
+
...page,
|
|
282
|
+
markdown: resolveLinks(body, page, pagesByFile, knownRoutes, baseUrl, contentRouteBase, broken, anchorLinks),
|
|
283
|
+
}));
|
|
284
|
+
if (broken.length && onBrokenLinks === 'throw') {
|
|
285
|
+
throw new Error(`Broken links:\n${broken.map((b) => ` - ${b}`).join('\n')}`);
|
|
286
|
+
}
|
|
287
|
+
if (onBrokenLinks === 'warn')
|
|
288
|
+
warnings.push(...broken);
|
|
289
|
+
const content = { pages, sidebars, categories, tags };
|
|
290
|
+
if (validate)
|
|
291
|
+
for (const page of pages)
|
|
292
|
+
validate(page, content);
|
|
293
|
+
return { ...content, warnings, anchorLinks };
|
|
294
|
+
}
|
|
295
|
+
/** Sidebar ID for a root directory. */
|
|
296
|
+
function sidebarId(root) {
|
|
297
|
+
return root || 'default';
|
|
298
|
+
}
|
|
299
|
+
/** The directory's index page, else its shallowest, lowest-positioned page. */
|
|
300
|
+
function landingRouteOf(base, pages) {
|
|
301
|
+
if (pages.some((page) => page.route === base))
|
|
302
|
+
return base;
|
|
303
|
+
const depth = (page) => page.route.slice(base.length).split('/').length;
|
|
304
|
+
return [...pages].sort((a, b) => depth(a) - depth(b) ||
|
|
305
|
+
(a.position ?? Number.MAX_SAFE_INTEGER) - (b.position ?? Number.MAX_SAFE_INTEGER) ||
|
|
306
|
+
a.route.localeCompare(b.route))[0].route;
|
|
307
|
+
}
|
|
308
|
+
/** Positioned items first, by position; the rest keep their name order. */
|
|
309
|
+
function sortItems(items) {
|
|
310
|
+
return items
|
|
311
|
+
.map((item, index) => ({ item, index }))
|
|
312
|
+
.sort((a, b) => (a.item.position ?? Infinity) - (b.item.position ?? Infinity) || a.index - b.index)
|
|
313
|
+
.map(({ item }) => item);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Rewrite links into site routes: relative `.md` references become the route of the file
|
|
317
|
+
* they name (anchors kept), `pathname://` is unwrapped, root-relative links and images get
|
|
318
|
+
* the base URL, and links under `routeBase` are checked against the known routes. Fenced
|
|
319
|
+
* code is left alone.
|
|
320
|
+
*/
|
|
321
|
+
function resolveLinks(markdown, page, pagesByFile, knownRoutes, baseUrl, routeBase, broken, anchorLinks) {
|
|
322
|
+
const { file } = page;
|
|
323
|
+
return markdown
|
|
324
|
+
.split(/(^ {0,3}(?:```|~~~)[\s\S]*?^ {0,3}(?:```|~~~))/m)
|
|
325
|
+
.map((chunk, i) => (i % 2 === 1 ? chunk : rewrite(chunk)))
|
|
326
|
+
.join('');
|
|
327
|
+
function rewrite(text) {
|
|
328
|
+
return text.replace(/(!?)\[([^\]]*)\]\(([^)\s]+)((?:\s+"[^"]*")?)\)/g, (match, bang, label, target, title) => {
|
|
329
|
+
if (target.startsWith('pathname://')) {
|
|
330
|
+
return `${bang}[${label}](${baseUrl}${target.slice('pathname://'.length)}${title})`;
|
|
331
|
+
}
|
|
332
|
+
if (target.startsWith('#')) {
|
|
333
|
+
if (!bang)
|
|
334
|
+
anchorLinks.push({ file, route: page.route, anchor: target.slice(1) });
|
|
335
|
+
return match;
|
|
336
|
+
}
|
|
337
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//'))
|
|
338
|
+
return match;
|
|
339
|
+
const [refPath, anchor] = target.split('#');
|
|
340
|
+
if (refPath.startsWith('/')) {
|
|
341
|
+
// Root-relative: served under the base URL, like Docusaurus' baseUrl
|
|
342
|
+
if (!bang && (refPath.startsWith(`${routeBase}/`) || refPath === routeBase)) {
|
|
343
|
+
const route = `${baseUrl}${refPath.replace(/\/$/, '')}`;
|
|
344
|
+
if (!knownRoutes.has(route))
|
|
345
|
+
broken.push(`${file}: link to unknown route ${target}`);
|
|
346
|
+
else if (anchor)
|
|
347
|
+
anchorLinks.push({ file, route, anchor });
|
|
348
|
+
}
|
|
349
|
+
return baseUrl ? `${bang}[${label}](${baseUrl}${target}${title})` : match;
|
|
350
|
+
}
|
|
351
|
+
if (bang)
|
|
352
|
+
return match;
|
|
353
|
+
if (!isDoc(refPath))
|
|
354
|
+
return match;
|
|
355
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(file), refPath));
|
|
356
|
+
const linked = pagesByFile.get(resolved);
|
|
357
|
+
if (!linked) {
|
|
358
|
+
broken.push(`${file}: unresolved link ${target}`);
|
|
359
|
+
return match;
|
|
360
|
+
}
|
|
361
|
+
if (anchor)
|
|
362
|
+
anchorLinks.push({ file, route: linked.route, anchor });
|
|
363
|
+
return `[${label}](${linked.route}${anchor ? `#${anchor}` : ''}${title})`;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers over sidebar trees. Pure functions on plain data: safe in the browser.
|
|
3
|
+
*/
|
|
4
|
+
import type { DocSource, NavItem, Tag } from './types.js';
|
|
5
|
+
export interface NavLink {
|
|
6
|
+
title: string;
|
|
7
|
+
route: string;
|
|
8
|
+
}
|
|
9
|
+
/** Every linkable entry in sidebar order (pages, and categories with their own page), deduped. */
|
|
10
|
+
export declare function flattenNav(items: NavItem[]): NavLink[];
|
|
11
|
+
/** Previous and next entries around `route`, in sidebar order: the page footer links. */
|
|
12
|
+
export declare function neighbours(items: NavItem[], route: string): {
|
|
13
|
+
previous: NavLink | null;
|
|
14
|
+
next: NavLink | null;
|
|
15
|
+
};
|
|
16
|
+
export interface Crumb {
|
|
17
|
+
label: string;
|
|
18
|
+
/** Undefined for a category without its own page */
|
|
19
|
+
route?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The categories leading to `route`, outermost first, ending with the page itself.
|
|
23
|
+
* Empty when the route isn't in the tree. A category whose own page is `route` ends the
|
|
24
|
+
* trail. Prepend the sidebar (and a home link) yourself if the layout wants them.
|
|
25
|
+
*/
|
|
26
|
+
export declare function breadcrumbs(items: NavItem[], route: string): Crumb[];
|
|
27
|
+
/** Whether `route` is in the tree under `item` (the item itself included). */
|
|
28
|
+
export declare function navContains(item: NavItem, route: string): boolean;
|
|
29
|
+
/** Linkable entries under a sidebar list, recursively (pages and categories with their own page). */
|
|
30
|
+
export declare function countPages(items: NavItem[]): number;
|
|
31
|
+
/** Tags in use among `pages`, with how many pages carry each, in vocabulary order. */
|
|
32
|
+
export declare function tagCounts(tags: Tag[], pages: Pick<DocSource, 'tags'>[]): (Tag & {
|
|
33
|
+
count: number;
|
|
34
|
+
})[];
|
|
35
|
+
/** Pages carrying `tag`, by title. */
|
|
36
|
+
export declare function pagesTagged<T extends Pick<DocSource, 'tags' | 'title'>>(pages: T[], tag: string): T[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers over sidebar trees. Pure functions on plain data: safe in the browser.
|
|
3
|
+
*/
|
|
4
|
+
/** Every linkable entry in sidebar order (pages, and categories with their own page), deduped. */
|
|
5
|
+
export function flattenNav(items) {
|
|
6
|
+
const flat = [];
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
const add = (title, route) => {
|
|
9
|
+
if (seen.has(route))
|
|
10
|
+
return;
|
|
11
|
+
seen.add(route);
|
|
12
|
+
flat.push({ title, route });
|
|
13
|
+
};
|
|
14
|
+
(function walk(list) {
|
|
15
|
+
for (const item of list) {
|
|
16
|
+
if (item.type === 'page')
|
|
17
|
+
add(item.title, item.route);
|
|
18
|
+
else {
|
|
19
|
+
if (item.route)
|
|
20
|
+
add(item.label, item.route);
|
|
21
|
+
walk(item.items);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
})(items);
|
|
25
|
+
return flat;
|
|
26
|
+
}
|
|
27
|
+
/** Previous and next entries around `route`, in sidebar order: the page footer links. */
|
|
28
|
+
export function neighbours(items, route) {
|
|
29
|
+
const flat = flattenNav(items);
|
|
30
|
+
const index = flat.findIndex((entry) => entry.route === route);
|
|
31
|
+
return {
|
|
32
|
+
previous: index > 0 ? flat[index - 1] : null,
|
|
33
|
+
next: index >= 0 && index < flat.length - 1 ? flat[index + 1] : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The categories leading to `route`, outermost first, ending with the page itself.
|
|
38
|
+
* Empty when the route isn't in the tree. A category whose own page is `route` ends the
|
|
39
|
+
* trail. Prepend the sidebar (and a home link) yourself if the layout wants them.
|
|
40
|
+
*/
|
|
41
|
+
export function breadcrumbs(items, route) {
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (item.type === 'page') {
|
|
44
|
+
if (item.route === route)
|
|
45
|
+
return [{ label: item.title, route: item.route }];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (item.route === route)
|
|
49
|
+
return [{ label: item.label, route: item.route }];
|
|
50
|
+
const inner = breadcrumbs(item.items, route);
|
|
51
|
+
if (inner.length)
|
|
52
|
+
return [{ label: item.label, route: item.route }, ...inner];
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
/** Whether `route` is in the tree under `item` (the item itself included). */
|
|
57
|
+
export function navContains(item, route) {
|
|
58
|
+
return item.type === 'page'
|
|
59
|
+
? item.route === route
|
|
60
|
+
: item.route === route || item.items.some((child) => navContains(child, route));
|
|
61
|
+
}
|
|
62
|
+
/** Linkable entries under a sidebar list, recursively (pages and categories with their own page). */
|
|
63
|
+
export function countPages(items) {
|
|
64
|
+
return items.reduce((n, item) => n + (item.type === 'page' ? 1 : (item.route ? 1 : 0) + countPages(item.items)), 0);
|
|
65
|
+
}
|
|
66
|
+
/** Tags in use among `pages`, with how many pages carry each, in vocabulary order. */
|
|
67
|
+
export function tagCounts(tags, pages) {
|
|
68
|
+
const counts = new Map();
|
|
69
|
+
for (const page of pages)
|
|
70
|
+
for (const tag of page.tags)
|
|
71
|
+
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
72
|
+
return tags
|
|
73
|
+
.filter((tag) => counts.has(tag.id))
|
|
74
|
+
.map((tag) => ({ ...tag, count: counts.get(tag.id) }));
|
|
75
|
+
}
|
|
76
|
+
/** Pages carrying `tag`, by title. */
|
|
77
|
+
export function pagesTagged(pages, tag) {
|
|
78
|
+
return pages
|
|
79
|
+
.filter((page) => page.tags.includes(tag))
|
|
80
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
81
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ParseOptions } from '../types/frontmatter.js';
|
|
2
|
+
import type { BrokenLinkPolicy, LoadedDocs } from './load.js';
|
|
3
|
+
import type { RenderedDoc } from './types.js';
|
|
4
|
+
export interface RenderDocsOptions {
|
|
5
|
+
/** Options for `parseMarkdown`, applied to every page */
|
|
6
|
+
parse?: ParseOptions;
|
|
7
|
+
/**
|
|
8
|
+
* Sanitize each page with `sanitizeDocsHtml` (from `@classic-homes/theme-docs/sanitize`).
|
|
9
|
+
* Removals are reported in `sanitized`. Default: false
|
|
10
|
+
*/
|
|
11
|
+
sanitize?: boolean;
|
|
12
|
+
/** Links to `#anchors` that don't exist on the target page. Default: `'warn'`, as Docusaurus */
|
|
13
|
+
onBrokenAnchors?: BrokenLinkPolicy;
|
|
14
|
+
/**
|
|
15
|
+
* Lift a page's leading `# Heading` out of the body: it becomes the title when the page
|
|
16
|
+
* has no `title` front matter, and the layout renders it (so content such as a lead
|
|
17
|
+
* paragraph can sit between title and body). Default: false
|
|
18
|
+
*/
|
|
19
|
+
hoistTitle?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface RenderDocsResult {
|
|
22
|
+
pages: RenderedDoc[];
|
|
23
|
+
/** Broken anchors (when `'warn'`) */
|
|
24
|
+
warnings: string[];
|
|
25
|
+
/** Pages the sanitizer changed, and what it removed */
|
|
26
|
+
sanitized: {
|
|
27
|
+
file: string;
|
|
28
|
+
removed: string[];
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Render every page loaded by `loadDocs` to HTML, then check `#anchor` links against the
|
|
33
|
+
* IDs the target pages actually have. Run it at build time: Shiki and marked then never
|
|
34
|
+
* load in the browser or the server runtime.
|
|
35
|
+
*/
|
|
36
|
+
export declare function renderDocs(docs: LoadedDocs, options?: RenderDocsOptions): Promise<RenderDocsResult>;
|
|
37
|
+
/** Searchable text: rendered HTML minus markup, code blocks and diagrams. */
|
|
38
|
+
export declare function plainText(html: string): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parseMarkdown } from '../parser/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Render every page loaded by `loadDocs` to HTML, then check `#anchor` links against the
|
|
4
|
+
* IDs the target pages actually have. Run it at build time: Shiki and marked then never
|
|
5
|
+
* load in the browser or the server runtime.
|
|
6
|
+
*/
|
|
7
|
+
export async function renderDocs(docs, options = {}) {
|
|
8
|
+
const { parse, sanitize = false, onBrokenAnchors = 'warn', hoistTitle = false } = options;
|
|
9
|
+
const sanitizeHtml = sanitize ? (await import('../sanitize/index.js')).sanitizeDocsHtml : null;
|
|
10
|
+
const sanitized = [];
|
|
11
|
+
const pages = await Promise.all(docs.pages.map(async ({ markdown, ...page }) => {
|
|
12
|
+
// Front matter was already read by loadDocs, so the body parses as plain markdown.
|
|
13
|
+
const rendered = await parseMarkdown(markdown, parse);
|
|
14
|
+
let html = rendered.html;
|
|
15
|
+
let { title } = page;
|
|
16
|
+
let toc = rendered.toc;
|
|
17
|
+
if (hoistTitle) {
|
|
18
|
+
const leading = /^\s*<h1\b[^>]*>([\s\S]*?)<\/h1>\s*/.exec(html);
|
|
19
|
+
if (leading) {
|
|
20
|
+
html = html.slice(leading[0].length);
|
|
21
|
+
if (page.frontmatter.title === undefined)
|
|
22
|
+
title = plainText(leading[1]);
|
|
23
|
+
toc = toc.filter((entry, i) => !(i === 0 && entry.level === 1));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (sanitizeHtml) {
|
|
27
|
+
const result = sanitizeHtml(html);
|
|
28
|
+
html = result.html;
|
|
29
|
+
if (result.removed.length)
|
|
30
|
+
sanitized.push({ file: page.file, removed: result.removed });
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
...page,
|
|
34
|
+
title,
|
|
35
|
+
html,
|
|
36
|
+
toc,
|
|
37
|
+
hasH1: toc.some((entry) => entry.level === 1) || /<h1[\s>]/.test(html),
|
|
38
|
+
text: plainText(html),
|
|
39
|
+
};
|
|
40
|
+
}));
|
|
41
|
+
const warnings = [];
|
|
42
|
+
if (onBrokenAnchors !== 'ignore') {
|
|
43
|
+
const idsByRoute = new Map(pages.map((p) => [p.route, elementIds(p.html)]));
|
|
44
|
+
const broken = docs.anchorLinks
|
|
45
|
+
.filter(({ route, anchor }) => {
|
|
46
|
+
const ids = idsByRoute.get(route);
|
|
47
|
+
// Category pages have no headings to link to; their routes were checked by loadDocs.
|
|
48
|
+
return ids !== undefined && !ids.has(decodeURIComponent(anchor));
|
|
49
|
+
})
|
|
50
|
+
.map(({ file, route, anchor }) => `${file}: link to missing anchor ${route}#${anchor}`);
|
|
51
|
+
if (broken.length && onBrokenAnchors === 'throw') {
|
|
52
|
+
throw new Error(`Broken anchors:\n${broken.map((b) => ` - ${b}`).join('\n')}`);
|
|
53
|
+
}
|
|
54
|
+
warnings.push(...broken);
|
|
55
|
+
}
|
|
56
|
+
return { pages, warnings, sanitized };
|
|
57
|
+
}
|
|
58
|
+
/** Every `id` (and `name`) attribute in a page: headings, footnotes, explicit anchors. */
|
|
59
|
+
function elementIds(html) {
|
|
60
|
+
return new Set([...html.matchAll(/\s(?:id|name)="([^"]+)"/g)].map((m) => decodeEntities(m[1])));
|
|
61
|
+
}
|
|
62
|
+
function decodeEntities(text) {
|
|
63
|
+
return text
|
|
64
|
+
.replace(/ /g, ' ')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"')
|
|
68
|
+
.replace(/�?39;/g, "'")
|
|
69
|
+
.replace(/&/g, '&');
|
|
70
|
+
}
|
|
71
|
+
const BLOCK_TAG = /<\/?(?:p|div|section|article|li|ul|ol|dl|dt|dd|h[1-6]|table|thead|tbody|tr|td|th|blockquote|br|hr|details|summary)\b[^>]*>/gi;
|
|
72
|
+
/** Searchable text: rendered HTML minus markup, code blocks and diagrams. */
|
|
73
|
+
export function plainText(html) {
|
|
74
|
+
return decodeEntities(html
|
|
75
|
+
.replace(/<pre[\s\S]*?<\/pre>/g, ' ')
|
|
76
|
+
.replace(/<svg[\s\S]*?<\/svg>/g, ' ')
|
|
77
|
+
.replace(/<a class="hash-link"[\s\S]*?<\/a>/g, '')
|
|
78
|
+
.replace(/<span class="external-link-hint">[\s\S]*?<\/span>/g, '')
|
|
79
|
+
// Block boundaries separate words; inline tags (links, emphasis) must not split them
|
|
80
|
+
.replace(BLOCK_TAG, ' ')
|
|
81
|
+
.replace(/<[^>]+>/g, ''))
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|