@waveso/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/CHANGELOG.md +84 -0
- package/README.md +111 -22
- package/dist/docs-error.d.ts +74 -0
- package/dist/docs-error.js +40 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +41 -19
- package/dist/next.js +117 -21
- package/dist/plugins/rehype-capture-toc.js +26 -15
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +48 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/search-dialog.d.ts +23 -7
- package/dist/react/search-dialog.js +46 -29
- package/dist/react/toc.js +28 -5
- package/dist/react/youtube.js +6 -4
- package/dist/render.d.ts +43 -9
- package/dist/render.js +112 -50
- package/dist/search-index.d.ts +32 -14
- package/dist/search-index.js +45 -51
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/source.d.ts +13 -1
- package/dist/source.js +152 -56
- package/dist/styles.css +236 -90
- package/dist/types.d.ts +41 -27
- package/package.json +13 -12
package/dist/source.js
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
|
+
import { docsError } from "./docs-error.js";
|
|
1
2
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
2
3
|
import { orderNavEntries, readDocsMeta } from "./meta.js";
|
|
3
|
-
import {
|
|
4
|
+
import { foldSegments } from "./plugins/remark-doc-links.js";
|
|
5
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
import matter from "gray-matter";
|
|
6
8
|
//#region src/source.ts
|
|
7
|
-
/**
|
|
8
|
-
* The source layer: a content directory on disk becomes `DocFile[]` plus a
|
|
9
|
-
* navigation tree.
|
|
10
|
-
*
|
|
11
|
-
* Node-only, and the only module that touches the filesystem. Everything it
|
|
12
|
-
* produces is plain data, so the result crosses the RSC boundary, a Vite
|
|
13
|
-
* virtual module or a JSON cache file without ceremony.
|
|
14
|
-
*/
|
|
15
9
|
/** Markdown only. MDX is deliberately out of scope for this package. */
|
|
16
10
|
const PAGE_EXTENSION = ".md";
|
|
17
11
|
/** A directory whose `index.md` is the directory's own route. */
|
|
@@ -83,7 +77,13 @@ function createDocsSource(config) {
|
|
|
83
77
|
}
|
|
84
78
|
function buildSource(config) {
|
|
85
79
|
let cached = null;
|
|
86
|
-
const load = () =>
|
|
80
|
+
const load = () => {
|
|
81
|
+
cached ??= scan(config).catch((err) => {
|
|
82
|
+
cached = null;
|
|
83
|
+
throw err;
|
|
84
|
+
});
|
|
85
|
+
return cached;
|
|
86
|
+
};
|
|
87
87
|
const isVisible = (file) => config.includeDrafts || file.frontmatter.draft !== true;
|
|
88
88
|
return {
|
|
89
89
|
config,
|
|
@@ -94,6 +94,10 @@ function buildSource(config) {
|
|
|
94
94
|
const { files } = await load();
|
|
95
95
|
return files.filter(isVisible);
|
|
96
96
|
},
|
|
97
|
+
async drafts() {
|
|
98
|
+
const { files } = await load();
|
|
99
|
+
return files.filter((file) => file.frontmatter.draft === true);
|
|
100
|
+
},
|
|
97
101
|
async find(segments) {
|
|
98
102
|
const { bySlug } = await load();
|
|
99
103
|
return bySlug.get(segments.join("/"));
|
|
@@ -109,7 +113,7 @@ function buildSource(config) {
|
|
|
109
113
|
}
|
|
110
114
|
async function scan(config) {
|
|
111
115
|
await assertContentDir(config.contentDir);
|
|
112
|
-
const root = await scanDir(config.contentDir, [], "", config);
|
|
116
|
+
const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await realpath(config.contentDir)]));
|
|
113
117
|
const files = [];
|
|
114
118
|
const bySlug = /* @__PURE__ */ new Map();
|
|
115
119
|
collect(root, files, bySlug);
|
|
@@ -123,15 +127,21 @@ async function assertContentDir(contentDir) {
|
|
|
123
127
|
try {
|
|
124
128
|
if ((await stat(contentDir)).isDirectory()) return;
|
|
125
129
|
} catch {}
|
|
126
|
-
throw
|
|
130
|
+
throw docsError("missing-content-dir", `Docs content directory not found: ${contentDir}\nSet \`contentDir\` to a directory of markdown files; relative paths resolve against the working directory (${process.cwd()}).`);
|
|
127
131
|
}
|
|
128
|
-
|
|
132
|
+
const SKIP = { kind: "skip" };
|
|
133
|
+
async function scanDir(absPath, segments, name, config, ancestors) {
|
|
129
134
|
const [meta, entries] = await Promise.all([readDocsMeta(absPath), readdir(absPath, { withFileTypes: true })]);
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
+
const classified = await Promise.all(entries.map((entry) => ({
|
|
136
|
+
entry,
|
|
137
|
+
name: entry.name.normalize("NFC")
|
|
138
|
+
})).sort((a, b) => a.name.localeCompare(b.name, "en")).map((listed) => classifyEntry(absPath, listed.entry, listed.name)));
|
|
139
|
+
const pageEntries = [];
|
|
140
|
+
const dirEntries = [];
|
|
141
|
+
for (const entry of classified) if (entry.kind === "page") pageEntries.push(entry);
|
|
142
|
+
else if (entry.kind === "dir" && !ancestors.has(entry.realPath)) dirEntries.push(entry);
|
|
143
|
+
const [pages, dirs] = await Promise.all([Promise.all(pageEntries.map((entry) => readPage(entry.absPath, entry.name, segments, config))), Promise.all(dirEntries.map((entry) => scanDir(entry.absPath, [...segments, entry.name], entry.name, config, new Set(ancestors).add(entry.realPath))))]);
|
|
144
|
+
const index = pages.find((page) => page.name === INDEX_NAME);
|
|
135
145
|
return {
|
|
136
146
|
name,
|
|
137
147
|
absPath,
|
|
@@ -143,7 +153,49 @@ async function scanDir(absPath, segments, name, config) {
|
|
|
143
153
|
dirs
|
|
144
154
|
};
|
|
145
155
|
}
|
|
146
|
-
|
|
156
|
+
/**
|
|
157
|
+
* What kind of content is this directory entry?
|
|
158
|
+
*
|
|
159
|
+
* ⚠️ `isFile()` AND `isDirectory()` ARE BOTH FALSE FOR A SYMBOLIC LINK, and
|
|
160
|
+
* `readdir` has no follow option — so a symlinked page, or a whole symlinked
|
|
161
|
+
* section, was absent from `all()`, `nav()`, the sitemap and the search index
|
|
162
|
+
* without a word. The cascade is worse than the omission: the first link to
|
|
163
|
+
* the missing page fails the build with `no such page exists`, which sends the
|
|
164
|
+
* author after a link that is perfectly correct.
|
|
165
|
+
*
|
|
166
|
+
* `stat` follows the link, so the target's kind decides — and the name filters
|
|
167
|
+
* still apply to the link's own name, which is what the URL is built from.
|
|
168
|
+
*/
|
|
169
|
+
async function classifyEntry(dirPath, entry, name) {
|
|
170
|
+
const absPath = path.join(dirPath, entry.name);
|
|
171
|
+
let isFile = entry.isFile();
|
|
172
|
+
let isDir = entry.isDirectory();
|
|
173
|
+
if (!isFile && !isDir) {
|
|
174
|
+
if (!entry.isSymbolicLink()) return SKIP;
|
|
175
|
+
let target;
|
|
176
|
+
try {
|
|
177
|
+
target = await stat(absPath);
|
|
178
|
+
} catch {
|
|
179
|
+
if (!isPageFile(name)) return SKIP;
|
|
180
|
+
throw docsError("broken-symlink", `@waveso/docs: ${absPath} is a broken symbolic link, and it names a markdown page — so skipping it would delete a route nobody asked to delete. Point it at an existing file, or remove the link.`);
|
|
181
|
+
}
|
|
182
|
+
isFile = target.isFile();
|
|
183
|
+
isDir = target.isDirectory();
|
|
184
|
+
}
|
|
185
|
+
if (isFile) return isPageFile(name) ? {
|
|
186
|
+
kind: "page",
|
|
187
|
+
absPath,
|
|
188
|
+
name: stripExtension(name)
|
|
189
|
+
} : SKIP;
|
|
190
|
+
if (isDir && !isIgnoredDir(name)) return {
|
|
191
|
+
kind: "dir",
|
|
192
|
+
absPath,
|
|
193
|
+
name,
|
|
194
|
+
realPath: await realpath(absPath)
|
|
195
|
+
};
|
|
196
|
+
return SKIP;
|
|
197
|
+
}
|
|
198
|
+
async function readPage(filePath, name, dirSegments, config) {
|
|
147
199
|
const raw = await readFile(filePath, "utf8");
|
|
148
200
|
const relativePath = toPosix(path.relative(config.contentDir, filePath));
|
|
149
201
|
let data;
|
|
@@ -154,25 +206,29 @@ async function readPage(filePath, dirSegments, config) {
|
|
|
154
206
|
content = parsed.content;
|
|
155
207
|
} catch (err) {
|
|
156
208
|
const reason = err instanceof Error ? err.message : String(err);
|
|
157
|
-
throw
|
|
209
|
+
throw docsError("invalid-frontmatter", `Could not parse the frontmatter block in ${relativePath}: ${reason}`, { cause: err });
|
|
158
210
|
}
|
|
159
|
-
const name = baseName(filePath);
|
|
160
211
|
const segments = name === INDEX_NAME ? [...dirSegments] : [...dirSegments, name];
|
|
212
|
+
const frontmatter = await parseFrontmatter(data, relativePath, config.frontmatterSchema);
|
|
213
|
+
for (const alias of frontmatter.aliases ?? []) toAliasRoute(alias, config.basePath, relativePath);
|
|
161
214
|
return {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
215
|
+
name,
|
|
216
|
+
doc: {
|
|
217
|
+
segments,
|
|
218
|
+
slug: segments.join("/"),
|
|
219
|
+
href: toHref(config.basePath, segments),
|
|
220
|
+
filePath,
|
|
221
|
+
relativePath,
|
|
222
|
+
frontmatter,
|
|
223
|
+
content
|
|
224
|
+
}
|
|
169
225
|
};
|
|
170
226
|
}
|
|
171
227
|
function collect(dir, files, bySlug) {
|
|
172
228
|
const own = dir.index ? [dir.index, ...dir.pages] : dir.pages;
|
|
173
|
-
for (const file of own) {
|
|
229
|
+
for (const { doc: file } of own) {
|
|
174
230
|
const clash = bySlug.get(file.slug);
|
|
175
|
-
if (clash) throw
|
|
231
|
+
if (clash) throw docsError("route-collision", `Two files claim the route "${file.href}": ${clash.relativePath} and ${file.relativePath}. Rename one, or delete the other — a directory index and a same-named sibling file collide.`);
|
|
176
232
|
bySlug.set(file.slug, file);
|
|
177
233
|
files.push(file);
|
|
178
234
|
}
|
|
@@ -193,9 +249,8 @@ function buildNav(dir, config) {
|
|
|
193
249
|
const mergeable = new Set(dir.dirs.filter((child) => child.index === void 0).map((child) => child.name));
|
|
194
250
|
const dirPages = /* @__PURE__ */ new Map();
|
|
195
251
|
for (const page of dir.pages) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
dirPages.set(name, page);
|
|
252
|
+
if (mergeable.has(page.name)) {
|
|
253
|
+
dirPages.set(page.name, page);
|
|
199
254
|
continue;
|
|
200
255
|
}
|
|
201
256
|
entries.push(toPageEntry(page, config));
|
|
@@ -207,51 +262,61 @@ function buildNav(dir, config) {
|
|
|
207
262
|
for (const child of dir.dirs) {
|
|
208
263
|
const children = buildNav(child, config);
|
|
209
264
|
const index = child.index ?? dirPages.get(child.name);
|
|
210
|
-
|
|
211
|
-
|
|
265
|
+
/**
|
|
266
|
+
* ⚠️ A DRAFT INDEX IS NOT A PUBLIC PAGE, AND ITS TITLE IS NOT EITHER.
|
|
267
|
+
*
|
|
268
|
+
* Only `href` used to be gated on visibility, so `secret/index.md` with
|
|
269
|
+
* `draft: true` still supplied the group heading — an unreleased codename
|
|
270
|
+
* rendered into the sidebar of a production build, with no link on it, so
|
|
271
|
+
* no click reveals it and only view-source shows it at all. Its `order`
|
|
272
|
+
* also still positioned the group among published ones.
|
|
273
|
+
*/
|
|
274
|
+
const visible = index && isVisibleIn(index.doc, config) ? index : void 0;
|
|
275
|
+
const title = groupTitle(child, visible?.doc);
|
|
276
|
+
const href = visible?.doc.href;
|
|
212
277
|
const group = {
|
|
213
278
|
type: "group",
|
|
214
279
|
title,
|
|
215
280
|
children,
|
|
216
281
|
...href !== void 0 ? { href } : {}
|
|
217
282
|
};
|
|
218
|
-
const node = children.length === 0 &&
|
|
283
|
+
const node = children.length === 0 && visible !== void 0 && href !== void 0 ? {
|
|
219
284
|
type: "page",
|
|
220
285
|
title,
|
|
221
286
|
href,
|
|
222
|
-
slug:
|
|
287
|
+
slug: visible.doc.slug
|
|
223
288
|
} : group;
|
|
224
|
-
const order =
|
|
289
|
+
const order = visible?.doc.frontmatter.order;
|
|
225
290
|
entries.push({
|
|
226
291
|
name: child.name,
|
|
227
292
|
title,
|
|
228
293
|
node,
|
|
229
294
|
inlineChildren: children,
|
|
230
|
-
...
|
|
295
|
+
...visible !== void 0 && href !== void 0 ? { indexNode: {
|
|
231
296
|
type: "page",
|
|
232
|
-
title: navTitle(
|
|
297
|
+
title: navTitle(visible.doc),
|
|
233
298
|
href,
|
|
234
|
-
slug:
|
|
299
|
+
slug: visible.doc.slug
|
|
235
300
|
} } : {},
|
|
236
301
|
...order !== void 0 ? { order } : {}
|
|
237
302
|
});
|
|
238
303
|
}
|
|
239
|
-
return orderNavEntries(entries, dir.meta, dir.metaPath);
|
|
304
|
+
return orderNavEntries(entries, dir.meta, dir.metaPath, dir.segments.length);
|
|
240
305
|
}
|
|
241
306
|
function toPageEntry(page, config) {
|
|
242
|
-
const title = navTitle(page);
|
|
243
|
-
const { order } = page.frontmatter;
|
|
307
|
+
const title = navTitle(page.doc);
|
|
308
|
+
const { order } = page.doc.frontmatter;
|
|
244
309
|
return {
|
|
245
|
-
name:
|
|
310
|
+
name: page.name,
|
|
246
311
|
title,
|
|
247
312
|
node: {
|
|
248
313
|
type: "page",
|
|
249
314
|
title,
|
|
250
|
-
href: page.href,
|
|
251
|
-
slug: page.slug
|
|
315
|
+
href: page.doc.href,
|
|
316
|
+
slug: page.doc.slug
|
|
252
317
|
},
|
|
253
318
|
...order !== void 0 ? { order } : {},
|
|
254
|
-
...isVisibleIn(page, config) ? {} : { hidden: true }
|
|
319
|
+
...isVisibleIn(page.doc, config) ? {} : { hidden: true }
|
|
255
320
|
};
|
|
256
321
|
}
|
|
257
322
|
/** Sidebars are narrow: `label` wins over `title` when the author set one. */
|
|
@@ -273,6 +338,18 @@ function isVisibleIn(file, config) {
|
|
|
273
338
|
return config.includeDrafts || file.frontmatter.draft !== true;
|
|
274
339
|
}
|
|
275
340
|
/**
|
|
341
|
+
* ⚠️ CHARACTERS `path-to-regexp` READS AS PATTERN SYNTAX.
|
|
342
|
+
*
|
|
343
|
+
* Next compiles every `redirects()` source with it, so an alias is not the
|
|
344
|
+
* literal URL it looks like. `v1:beta` compiles to `/docs/v1([^/]+?)`, which
|
|
345
|
+
* `next build` accepts without a word and which then 308s the genuinely
|
|
346
|
+
* prerendered `/docs/v1-guide` away — config redirects run before filesystem
|
|
347
|
+
* routes, so the real page is unreachable in production and nothing reports
|
|
348
|
+
* it. `c++` is the loud sibling: the build aborts with `Unexpected MODIFIER at
|
|
349
|
+
* 7`, naming an offset into a string the author never wrote and no file.
|
|
350
|
+
*/
|
|
351
|
+
const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
|
|
352
|
+
/**
|
|
276
353
|
* A former URL from `aliases` frontmatter, as a route.
|
|
277
354
|
*
|
|
278
355
|
* `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
|
|
@@ -282,12 +359,17 @@ function isVisibleIn(file, config) {
|
|
|
282
359
|
*
|
|
283
360
|
* Shared by both adapters so they agree on which routes exist: an alias is a
|
|
284
361
|
* redirect the host installs, so a link to one resolves, and a link that
|
|
285
|
-
* builds under Next must build under Vite.
|
|
362
|
+
* builds under Next must build under Vite. The source scan calls it too, so
|
|
363
|
+
* every rejection below names the markdown file at the moment it is read.
|
|
286
364
|
*/
|
|
287
365
|
function toAliasRoute(alias, basePath, sourceLabel) {
|
|
288
|
-
const trimmed = alias.trim()
|
|
289
|
-
if (trimmed === "") throw
|
|
290
|
-
|
|
366
|
+
const trimmed = alias.trim();
|
|
367
|
+
if (trimmed.split("/").some((part) => part === "." || part === "..")) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} has a '.' or '..' segment. An alias is a former URL relative to the docs base path, not a path on disk: write \`aliases: [legacy/old-name]\`.`);
|
|
368
|
+
const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
|
|
369
|
+
if (pattern !== null) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} contains '${pattern[0]}', which Next compiles as redirect pattern syntax rather than as part of the URL — the redirect then swallows every page whose route the pattern happens to match, or fails the build. Remove the character; an alias is a literal former URL.`);
|
|
370
|
+
const segments = foldSegments([], trimmed);
|
|
371
|
+
if (segments === void 0 || segments.length === 0) throw docsError("invalid-alias", `@waveso/docs: ${sourceLabel} has an empty entry in its \`aliases\` frontmatter. Each alias is a former URL for this page, relative to the docs base path — e.g. \`aliases: [quickstart]\`.`);
|
|
372
|
+
return `${basePath}/${encodeSegments(segments)}`;
|
|
291
373
|
}
|
|
292
374
|
/**
|
|
293
375
|
* ⚠️ `_` AND `.` BOTH, MATCHING `isIgnoredDir` BELOW — which is what this did
|
|
@@ -302,18 +384,32 @@ function toAliasRoute(alias, basePath, sourceLabel) {
|
|
|
302
384
|
* first. Docusaurus and Nextra skip both forms.
|
|
303
385
|
*/
|
|
304
386
|
function isPageFile(name) {
|
|
305
|
-
return !name.startsWith(".") && !name.startsWith("_") && name.
|
|
387
|
+
return !name.startsWith(".") && !name.startsWith("_") && path.extname(name).toLowerCase() === PAGE_EXTENSION;
|
|
306
388
|
}
|
|
307
389
|
/** `_drafts/` and `.git/` are not content. */
|
|
308
390
|
function isIgnoredDir(name) {
|
|
309
391
|
return name.startsWith(".") || name.startsWith("_");
|
|
310
392
|
}
|
|
311
|
-
|
|
312
|
-
|
|
393
|
+
/** `getting-started.MD` -> `getting-started`. */
|
|
394
|
+
function stripExtension(name) {
|
|
395
|
+
return name.slice(0, name.length - path.extname(name).length);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Percent-encode the segments, and only here.
|
|
399
|
+
*
|
|
400
|
+
* `segments` and `slug` stay raw on purpose: Next decodes route params before
|
|
401
|
+
* they reach `find()`, so an encoded slug would match nothing. Unencoded, a
|
|
402
|
+
* `#`, `?` or `%` in a filename stops being part of the path — the sitemap
|
|
403
|
+
* emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
|
|
404
|
+
* and `og:url` are built by the same call — while a space produced a URL that
|
|
405
|
+
* only works until something re-encodes it.
|
|
406
|
+
*/
|
|
407
|
+
function encodeSegments(segments) {
|
|
408
|
+
return segments.map(encodeURIComponent).join("/");
|
|
313
409
|
}
|
|
314
410
|
function toHref(basePath, segments) {
|
|
315
411
|
if (segments.length === 0) return basePath === "" ? "/" : basePath;
|
|
316
|
-
return `${basePath}/${segments
|
|
412
|
+
return `${basePath}/${encodeSegments(segments)}`;
|
|
317
413
|
}
|
|
318
414
|
function normalizeBasePath(basePath) {
|
|
319
415
|
const trimmed = basePath.trim().replace(/\/+$/, "");
|