@waveso/docs 0.2.0 → 0.4.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +490 -75
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +2 -57
  10. package/dist/docs-error.js +3 -15
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/next.d.ts +153 -28
  14. package/dist/next.js +65 -33
  15. package/dist/plugins/rehype-capture-toc.js +26 -5
  16. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  17. package/dist/plugins/rehype-code-frame.js +88 -0
  18. package/dist/plugins/rehype-code-language.js +7 -1
  19. package/dist/react/code-runtime.d.ts +14 -0
  20. package/dist/react/code-runtime.js +161 -0
  21. package/dist/react/doc-content.d.ts +39 -2
  22. package/dist/react/doc-content.js +42 -10
  23. package/dist/react/layout.d.ts +44 -0
  24. package/dist/react/layout.js +65 -0
  25. package/dist/react/nav.d.ts +28 -0
  26. package/dist/react/nav.js +70 -0
  27. package/dist/react/nearest-scroll-top.d.ts +45 -0
  28. package/dist/react/nearest-scroll-top.js +44 -0
  29. package/dist/react/next-link.d.ts +34 -0
  30. package/dist/react/next-link.js +30 -0
  31. package/dist/react/next-nav.d.ts +11 -0
  32. package/dist/react/next-nav.js +32 -0
  33. package/dist/react/next-search.d.ts +22 -0
  34. package/dist/react/next-search.js +52 -0
  35. package/dist/react/search-dialog.d.ts +53 -10
  36. package/dist/react/search-dialog.js +147 -47
  37. package/dist/react/shell-labels.d.ts +43 -0
  38. package/dist/react/shell-labels.js +27 -0
  39. package/dist/react/sidebar.d.ts +38 -3
  40. package/dist/react/sidebar.js +104 -12
  41. package/dist/react/skip-link.d.ts +1 -9
  42. package/dist/react/skip-link.js +6 -5
  43. package/dist/react/toc.d.ts +12 -4
  44. package/dist/react/toc.js +18 -7
  45. package/dist/react/youtube.d.ts +31 -5
  46. package/dist/react/youtube.js +76 -54
  47. package/dist/render.d.ts +35 -1
  48. package/dist/render.js +35 -14
  49. package/dist/route-path.d.ts +46 -0
  50. package/dist/route-path.js +51 -0
  51. package/dist/search-index.d.ts +6 -23
  52. package/dist/search-index.js +6 -51
  53. package/dist/sitemap-limit.d.ts +34 -0
  54. package/dist/sitemap-limit.js +37 -0
  55. package/dist/source.d.ts +1 -23
  56. package/dist/source.js +40 -43
  57. package/dist/styles.css +1001 -106
  58. package/dist/types.d.ts +11 -2
  59. package/package.json +58 -23
package/dist/render.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { docsError } from "./docs-error.js";
2
2
  import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
3
3
  import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
4
+ import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
4
5
  import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
5
6
  import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
6
7
  import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
7
- import { foldSegments, remarkDocLinks } from "./plugins/remark-doc-links.js";
8
+ import { foldSegments, remarkDocLinks, resolveMarkdownLink } from "./plugins/remark-doc-links.js";
8
9
  import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
9
10
  import { remarkYouTube } from "./plugins/remark-youtube.js";
10
11
  import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
@@ -145,7 +146,7 @@ function titleHeadingNode(title) {
145
146
  *
146
147
  * The tree is the payload: it crosses the RSC boundary, so every byte is
147
148
  * shipped to every reader.
148
- * Positions are 38% of that JSON on a typical page — line and column offsets
149
+ * Positions are roughly a third of that JSON — line and column offsets
149
150
  * into a markdown file the browser does not have and cannot fetch. Nothing
150
151
  * downstream reads them: link errors are reported from positions captured
151
152
  * during the mdast phase, and the TOC works off ids.
@@ -188,28 +189,48 @@ function stripPositions(tree) {
188
189
  * 8. `rehypeFallbackHeadingIds` — before slugging, so an emoji-only heading
189
190
  * never seeds the collision counter with `''`.
190
191
  * 9. `rehypeSlug` — assigns heading ids.
191
- * 10. `rehypeCaptureToc` reads those ids. Before autolinking, so heading
192
- * text is captured without the appended `#`.
193
- * 11. `rehypeAutolinkHeadings` appends the permalink.
192
+ * 10. `rehypeAutolinkHeadings` appends the permalink.
193
+ * 11. `rehypePlugins` — the consumer's, after slugging and autolinking so
194
+ * heading ids exist, and before the code steps so
195
+ * a `<pre>` is still the author's text.
194
196
  * 12. `rehypeNormalizeCodeLanguage` — immediately before Shiki, which is the
195
197
  * last moment `class="language-JSON"` exists.
196
- * 13. `rehypeShikiFromHighlighter` near-last: it replaces `<pre><code>`
198
+ * 13. `rehypeCodeFrame` the one step wide window: after 12, which folds
199
+ * the language and disguises excluded fences, and
200
+ * before Shiki, which destroys `code.data.meta`
201
+ * and with it the fence's `title="…"`.
202
+ * 14. `rehypeShikiFromHighlighter` — near-last: it replaces `<pre><code>`
197
203
  * wholesale, and anything walking code blocks
198
204
  * afterwards would be walking Shiki's token spans.
199
- * 14. `rehypeRestoreExcludedCode` — the other side of step 12's disguise.
200
- * 15. `rehypeFlattenRoots` — last of all, because Shiki is what splices a
201
- * `root` into `root.children` and the published
205
+ * 15. `rehypeRestoreExcludedCode` — the other side of step 12's disguise.
206
+ * 16. `rehypeFlattenRoots` — because Shiki is what splices a `root` into
207
+ * `root.children` and the published
202
208
  * `RenderedDoc.hast` type says that cannot happen.
209
+ * Step 13 is the first thing to put a `root`
210
+ * inside an *element* rather than at the top, so
211
+ * this recursing into element children is now
212
+ * load-bearing rather than defensive.
213
+ * 17. `rehypeCaptureToc` — DEAD LAST, and that is the design rather than an
214
+ * ordering detail. The TOC is then read off the
215
+ * identical tree `extractSearchRecords` walks, so
216
+ * a consumer plugin cannot put the two out of step
217
+ * — and no validation pass or error has to exist
218
+ * to notice when it does. Measured both drifts
219
+ * before the move: a plugin deleting a heading id
220
+ * left `toc` pointing at an id no longer in the
221
+ * DOM while search silently dropped the section;
222
+ * one adding an `<h2>` produced a search record
223
+ * with no TOC entry. Both silent.
203
224
  */
204
225
  async function buildProcessor(options, themes, highlighterPromise) {
205
226
  const highlighter = await highlighterPromise;
206
- return unified().use(remarkParse).use(remarkGfm).use(remarkDocLinks, {
227
+ return unified().use(remarkParse).use(remarkGfm).use(options.remarkPlugins ?? []).use(remarkDocLinks, {
207
228
  basePath: options.config.basePath,
208
229
  ...options.linkResolver === void 0 ? {} : { resolve: options.linkResolver }
209
230
  }).use(remarkUnwrapImages).use(remarkYouTube).use(remarkRehype, {
210
231
  allowDangerousHtml: false,
211
232
  footnoteLabelProperties: { className: ["wave-docs-sr-only"] }
212
- }).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(rehypeCaptureToc).use(rehypeAutolinkHeadings, {
233
+ }).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(rehypeAutolinkHeadings, {
213
234
  behavior: "append",
214
235
  content: HEADING_ANCHOR_CONTENT,
215
236
  properties: {
@@ -217,13 +238,13 @@ async function buildProcessor(options, themes, highlighterPromise) {
217
238
  ariaHidden: "true",
218
239
  tabIndex: -1
219
240
  }
220
- }).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeShikiFromHighlighter, highlighter, {
241
+ }).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame).use(rehypeShikiFromHighlighter, highlighter, {
221
242
  themes,
222
243
  defaultColor: false,
223
244
  fallbackLanguage: "text",
224
245
  defaultLanguage: "text",
225
246
  addLanguageClass: true
226
- }).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).freeze();
247
+ }).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).use(rehypeCaptureToc).freeze();
227
248
  }
228
249
  /**
229
250
  * Create a renderer.
@@ -338,4 +359,4 @@ function createDocsRenderer(options) {
338
359
  } };
339
360
  }
340
361
  //#endregion
341
- export { createDocsRenderer };
362
+ export { createDocsRenderer, resolveMarkdownLink };
@@ -0,0 +1,46 @@
1
+ //#region src/route-path.d.ts
2
+ /**
3
+ * Turning route segments into a URL path.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`. `toAliasRoute`
6
+ * was exported from `./source` and therefore public, which froze both its
7
+ * signature and the wording of three error messages under semver, for a
8
+ * function no README mentions and only this package calls. It lives here with
9
+ * `encodeSegments` because the two have to agree: an alias and a link that
10
+ * spell the same page differently produce a redirect no request can match.
11
+ */
12
+ /**
13
+ * Percent-encode the segments, and only here.
14
+ *
15
+ * `segments` and `slug` stay raw on purpose: Next decodes route params before
16
+ * they reach `find()`, so an encoded slug would match nothing. Unencoded, a
17
+ * `#`, `?` or `%` in a filename stops being part of the path — the sitemap
18
+ * emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
19
+ * and `og:url` are built by the same call — while a space produced a URL that
20
+ * only works until something re-encodes it.
21
+ */
22
+ declare function encodeSegments(segments: readonly string[]): string;
23
+ /**
24
+ * A former URL from `aliases` frontmatter, as a route.
25
+ *
26
+ * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
27
+ * Leading and trailing slashes are tolerated because authors write them, but
28
+ * the value is always relative to the base path — an alias of `'/docs/old'` on
29
+ * a `/docs` site would produce `/docs/docs/old`.
30
+ *
31
+ * Shared by both adapters so they agree on which routes exist: an alias is a
32
+ * redirect the host installs, so a link to one resolves, and a link that
33
+ * builds under Next must build under Vite. The source scan calls it too, so
34
+ * every rejection below names the markdown file at the moment it is read.
35
+ */
36
+ declare function toAliasRoute(alias: string, basePath: string,
37
+ /**
38
+ * The source path, for the error. A STRING rather than the whole `DocFile`
39
+ * it used to take: this function dereferenced exactly one property of it, and
40
+ * demanding the object meant a cache reader or a manifest-driven redirect
41
+ * table had to fabricate a `DocFile` to agree with the package about which
42
+ * routes exist. That is the reason it is exported at all.
43
+ */
44
+ sourceLabel: string): string;
45
+ //#endregion
46
+ export { encodeSegments, toAliasRoute };
@@ -0,0 +1,51 @@
1
+ import { docsError } from "./docs-error.js";
2
+ import { foldSegments } from "./plugins/remark-doc-links.js";
3
+ //#region src/route-path.ts
4
+ /**
5
+ * Turning route segments into a URL path.
6
+ *
7
+ * Private — deliberately not an entry point in `package.json`. `toAliasRoute`
8
+ * was exported from `./source` and therefore public, which froze both its
9
+ * signature and the wording of three error messages under semver, for a
10
+ * function no README mentions and only this package calls. It lives here with
11
+ * `encodeSegments` because the two have to agree: an alias and a link that
12
+ * spell the same page differently produce a redirect no request can match.
13
+ */
14
+ /**
15
+ * Percent-encode the segments, and only here.
16
+ *
17
+ * `segments` and `slug` stay raw on purpose: Next decodes route params before
18
+ * they reach `find()`, so an encoded slug would match nothing. Unencoded, a
19
+ * `#`, `?` or `%` in a filename stops being part of the path — the sitemap
20
+ * emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
21
+ * and `og:url` are built by the same call — while a space produced a URL that
22
+ * only works until something re-encodes it.
23
+ */
24
+ function encodeSegments(segments) {
25
+ return segments.map(encodeURIComponent).join("/");
26
+ }
27
+ const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
28
+ /**
29
+ * A former URL from `aliases` frontmatter, as a route.
30
+ *
31
+ * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
32
+ * Leading and trailing slashes are tolerated because authors write them, but
33
+ * the value is always relative to the base path — an alias of `'/docs/old'` on
34
+ * a `/docs` site would produce `/docs/docs/old`.
35
+ *
36
+ * Shared by both adapters so they agree on which routes exist: an alias is a
37
+ * redirect the host installs, so a link to one resolves, and a link that
38
+ * builds under Next must build under Vite. The source scan calls it too, so
39
+ * every rejection below names the markdown file at the moment it is read.
40
+ */
41
+ function toAliasRoute(alias, basePath, sourceLabel) {
42
+ const trimmed = alias.trim();
43
+ 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]\`.`);
44
+ const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
45
+ 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.`);
46
+ const segments = foldSegments([], trimmed);
47
+ 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]\`.`);
48
+ return `${basePath}/${encodeSegments(segments)}`;
49
+ }
50
+ //#endregion
51
+ export { encodeSegments, toAliasRoute };
@@ -35,10 +35,11 @@ declare function extractSearchRecords(doc: RenderedDoc): SearchRecord[];
35
35
  /**
36
36
  * Build a serialised MiniSearch index from extracted records.
37
37
  *
38
- * The return value is JSON, ready for `MiniSearch.loadJSON` on the client or
39
- * for {@link writeSearchIndex} to put on disk. The output is byte-stable for a
40
- * given record list, so an index committed to the repository does not dirty
41
- * the diff on every build.
38
+ * The return value is JSON, ready for `MiniSearch.loadJSON` on the client.
39
+ * `docs.searchIndex` serves exactly this; reach for `buildSearchIndex`
40
+ * directly only when you need an artifact that route cannot produce. The
41
+ * output is byte-stable for a given record list, which is what lets the
42
+ * route ship a strong `ETag`.
42
43
  *
43
44
  * ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
44
45
  * `SearchDialog`'s `searchOptions`. Both sides feed it through
@@ -47,23 +48,5 @@ declare function extractSearchRecords(doc: RenderedDoc): SearchRecord[];
47
48
  * spell: zero results, no error, nothing in the console.
48
49
  */
49
50
  declare function buildSearchIndex(records: SearchRecord[], options?: Partial<Options<SearchRecord>>): string;
50
- /**
51
- * Write the serialised index to `outFile`, creating parent directories.
52
- *
53
- * Returns the byte size written, so a build step can log it or assert a
54
- * budget — a docs index that quietly crosses a megabyte is a regression
55
- * nobody notices until the dialog takes a second to open.
56
- *
57
- * ⚠️ WRITTEN BESIDE THE TARGET AND RENAMED OVER IT, NEVER INTO IT. The target
58
- * is normally `public/search-index.json`, a live static asset: writing in
59
- * place truncates it to zero and grows it back in 1 MiB chunks, and a fetch
60
- * landing in that window gets a 200 with a half-written body. `response.ok`
61
- * passes, the parse throws, and the dialog is stuck in its error state —
62
- * *"Try reloading the page"* — for every visitor, reloading forever, until
63
- * someone redeploys content that did not change. `rename` is atomic within a
64
- * filesystem, so a reader sees either the whole old file or the whole new one;
65
- * it also makes two concurrent builds safe.
66
- */
67
- declare function writeSearchIndex(records: SearchRecord[], outFile: string, options?: Partial<Options<SearchRecord>>): Promise<number>;
68
51
  //#endregion
69
- export { buildSearchIndex, extractSearchRecords, writeSearchIndex };
52
+ export { buildSearchIndex, extractSearchRecords };
@@ -1,22 +1,7 @@
1
1
  import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
2
2
  import { mergeSearchOptions } from "./search-options.js";
3
- import { mkdir, rename, rm, writeFile } from "node:fs/promises";
4
- import path from "node:path";
5
3
  import MiniSearch from "minisearch";
6
4
  //#region src/search-index.ts
7
- /**
8
- * Build-time search index construction.
9
- *
10
- * Node-only, and deliberately so: the markdown parser, the hast walk and
11
- * MiniSearch's index builder all run once per build, and the browser receives
12
- * nothing but the serialised result. `src/react/search-dialog.tsx` is the
13
- * matching client half.
14
- *
15
- * MiniSearch over Fuse.js is a measured choice, not a taste one: on a 282-page
16
- * corpus Fuse ran 96.6 ms median / 298 ms max per query against MiniSearch's
17
- * 1.35 ms / 3.84 ms. Fuse is a fuzzy short-string matcher routinely
18
- * misapplied to full text.
19
- */
20
5
  /** `<h1>`…`<h6>` to their numeric depth. */
21
6
  const HEADING_DEPTHS = /* @__PURE__ */ new Map([
22
7
  ["h1", 1],
@@ -217,10 +202,11 @@ function collapseWhitespace(text) {
217
202
  /**
218
203
  * Build a serialised MiniSearch index from extracted records.
219
204
  *
220
- * The return value is JSON, ready for `MiniSearch.loadJSON` on the client or
221
- * for {@link writeSearchIndex} to put on disk. The output is byte-stable for a
222
- * given record list, so an index committed to the repository does not dirty
223
- * the diff on every build.
205
+ * The return value is JSON, ready for `MiniSearch.loadJSON` on the client.
206
+ * `docs.searchIndex` serves exactly this; reach for `buildSearchIndex`
207
+ * directly only when you need an artifact that route cannot produce. The
208
+ * output is byte-stable for a given record list, which is what lets the
209
+ * route ship a strong `ETag`.
224
210
  *
225
211
  * ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
226
212
  * `SearchDialog`'s `searchOptions`. Both sides feed it through
@@ -233,36 +219,5 @@ function buildSearchIndex(records, options = {}) {
233
219
  index.addAll(records);
234
220
  return JSON.stringify(index);
235
221
  }
236
- /**
237
- * Write the serialised index to `outFile`, creating parent directories.
238
- *
239
- * Returns the byte size written, so a build step can log it or assert a
240
- * budget — a docs index that quietly crosses a megabyte is a regression
241
- * nobody notices until the dialog takes a second to open.
242
- *
243
- * ⚠️ WRITTEN BESIDE THE TARGET AND RENAMED OVER IT, NEVER INTO IT. The target
244
- * is normally `public/search-index.json`, a live static asset: writing in
245
- * place truncates it to zero and grows it back in 1 MiB chunks, and a fetch
246
- * landing in that window gets a 200 with a half-written body. `response.ok`
247
- * passes, the parse throws, and the dialog is stuck in its error state —
248
- * *"Try reloading the page"* — for every visitor, reloading forever, until
249
- * someone redeploys content that did not change. `rename` is atomic within a
250
- * filesystem, so a reader sees either the whole old file or the whole new one;
251
- * it also makes two concurrent builds safe.
252
- */
253
- async function writeSearchIndex(records, outFile, options = {}) {
254
- const json = buildSearchIndex(records, options);
255
- const absolute = path.resolve(outFile);
256
- const temporary = `${absolute}.tmp-${process.pid}`;
257
- await mkdir(path.dirname(absolute), { recursive: true });
258
- try {
259
- await writeFile(temporary, json, "utf8");
260
- await rename(temporary, absolute);
261
- } catch (error) {
262
- await rm(temporary, { force: true });
263
- throw error;
264
- }
265
- return Buffer.byteLength(json, "utf8");
266
- }
267
222
  //#endregion
268
- export { buildSearchIndex, extractSearchRecords, writeSearchIndex };
223
+ export { buildSearchIndex, extractSearchRecords };
@@ -0,0 +1,34 @@
1
+ //#region src/sitemap-limit.d.ts
2
+ /**
3
+ * Google's per-sitemap URL cap, and the warning for crossing it.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`. It lives in its
6
+ * own module for one reason: the branch is otherwise untestable. `next.ts` held
7
+ * the limit and the `console.warn` inline, and reaching them from a test meant
8
+ * writing 50,001 markdown files to a temporary directory, so the test that
9
+ * claimed to cover it ("warns rather than silently emitting an oversized
10
+ * sitemap") built a one-page site and asserted the warning did *not* fire. It
11
+ * could only ever fail if the comparison were inverted.
12
+ *
13
+ * A count is the whole input. Split out, the arithmetic and the wording are
14
+ * checkable in microseconds, and `createDocsSitemap`'s own test keeps covering
15
+ * the case that matters at the integration level: an ordinary sitemap stays
16
+ * quiet.
17
+ */
18
+ /**
19
+ * The cap.
20
+ *
21
+ * 50,000 URLs or 50 MB uncompressed, whichever comes first; a crawler rejects
22
+ * the file whole rather than truncating it.
23
+ */
24
+ declare const SITEMAP_URL_LIMIT = 50000;
25
+ /**
26
+ * The warning for a sitemap of `count` URLs, or `undefined` when it fits.
27
+ *
28
+ * Splitting belongs to the caller — Next's `generateSitemaps` plus a slice of
29
+ * the returned array is three lines — but silently emitting a file no crawler
30
+ * will read is not something to discover from Search Console six weeks later.
31
+ */
32
+ declare function sitemapLimitWarning(count: number): string | undefined;
33
+ //#endregion
34
+ export { SITEMAP_URL_LIMIT, sitemapLimitWarning };
@@ -0,0 +1,37 @@
1
+ //#region src/sitemap-limit.ts
2
+ /**
3
+ * Google's per-sitemap URL cap, and the warning for crossing it.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`. It lives in its
6
+ * own module for one reason: the branch is otherwise untestable. `next.ts` held
7
+ * the limit and the `console.warn` inline, and reaching them from a test meant
8
+ * writing 50,001 markdown files to a temporary directory, so the test that
9
+ * claimed to cover it ("warns rather than silently emitting an oversized
10
+ * sitemap") built a one-page site and asserted the warning did *not* fire. It
11
+ * could only ever fail if the comparison were inverted.
12
+ *
13
+ * A count is the whole input. Split out, the arithmetic and the wording are
14
+ * checkable in microseconds, and `createDocsSitemap`'s own test keeps covering
15
+ * the case that matters at the integration level: an ordinary sitemap stays
16
+ * quiet.
17
+ */
18
+ /**
19
+ * The cap.
20
+ *
21
+ * 50,000 URLs or 50 MB uncompressed, whichever comes first; a crawler rejects
22
+ * the file whole rather than truncating it.
23
+ */
24
+ const SITEMAP_URL_LIMIT = 5e4;
25
+ /**
26
+ * The warning for a sitemap of `count` URLs, or `undefined` when it fits.
27
+ *
28
+ * Splitting belongs to the caller — Next's `generateSitemaps` plus a slice of
29
+ * the returned array is three lines — but silently emitting a file no crawler
30
+ * will read is not something to discover from Search Console six weeks later.
31
+ */
32
+ function sitemapLimitWarning(count) {
33
+ if (count <= 5e4) return void 0;
34
+ return `@waveso/docs: this sitemap has ${count} URLs, above Google's limit of ${SITEMAP_URL_LIMIT}. Split it with Next's \`generateSitemaps\` and slice the array this returns.`;
35
+ }
36
+ //#endregion
37
+ export { SITEMAP_URL_LIMIT, sitemapLimitWarning };
package/dist/source.d.ts CHANGED
@@ -53,27 +53,5 @@ declare function resolveDocsConfig<TFrontmatter extends DocFrontmatter = DocFron
53
53
  * Create (or reuse) the source for a content directory.
54
54
  */
55
55
  declare function createDocsSource<TFrontmatter extends DocFrontmatter = DocFrontmatter>(config: DocsConfig<TFrontmatter>): DocsSource<TFrontmatter>;
56
- /**
57
- * A former URL from `aliases` frontmatter, as a route.
58
- *
59
- * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
60
- * Leading and trailing slashes are tolerated because authors write them, but
61
- * the value is always relative to the base path — an alias of `'/docs/old'` on
62
- * a `/docs` site would produce `/docs/docs/old`.
63
- *
64
- * Shared by both adapters so they agree on which routes exist: an alias is a
65
- * redirect the host installs, so a link to one resolves, and a link that
66
- * builds under Next must build under Vite. The source scan calls it too, so
67
- * every rejection below names the markdown file at the moment it is read.
68
- */
69
- declare function toAliasRoute(alias: string, basePath: string,
70
- /**
71
- * The source path, for the error. A STRING rather than the whole `DocFile`
72
- * it used to take: this function dereferenced exactly one property of it, and
73
- * demanding the object meant a cache reader or a manifest-driven redirect
74
- * table had to fabricate a `DocFile` to agree with the package about which
75
- * routes exist. That is the reason it is exported at all.
76
- */
77
- sourceLabel: string): string;
78
56
  //#endregion
79
- export { DocsSource, createDocsSource, resolveDocsConfig, toAliasRoute };
57
+ export { DocsSource, createDocsSource, resolveDocsConfig };
package/dist/source.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { docsError } from "./docs-error.js";
2
2
  import { parseFrontmatter } from "./frontmatter.js";
3
3
  import { orderNavEntries, readDocsMeta } from "./meta.js";
4
- import { foldSegments } from "./plugins/remark-doc-links.js";
4
+ import { encodeSegments, toAliasRoute } from "./route-path.js";
5
5
  import { readFile, readdir, realpath, stat } from "node:fs/promises";
6
6
  import path from "node:path";
7
- import matter from "gray-matter";
7
+ import { VFile } from "vfile";
8
+ import { matter } from "vfile-matter";
8
9
  //#region src/source.ts
9
10
  /** Markdown only. MDX is deliberately out of scope for this package. */
10
11
  const PAGE_EXTENSION = ".md";
@@ -20,7 +21,11 @@ const INDEX_NAME = "index";
20
21
  */
21
22
  function resolveDocsConfig(config) {
22
23
  return {
23
- contentDir: path.resolve(process.cwd(), config.contentDir),
24
+ contentDir: path.resolve(
25
+ /*turbopackIgnore: true*/
26
+ process.cwd(),
27
+ config.contentDir
28
+ ),
24
29
  basePath: normalizeBasePath(config.basePath ?? "/docs"),
25
30
  includeDrafts: config.includeDrafts ?? false,
26
31
  assertLinks: config.assertLinks ?? true,
@@ -87,6 +92,33 @@ function buildSource(config) {
87
92
  const isVisible = (file) => config.includeDrafts || file.frontmatter.draft !== true;
88
93
  return {
89
94
  config,
95
+ /**
96
+ * Throw the scan away. The next query reads the disk again.
97
+ *
98
+ * ⚠️ A STAT-WALK DIRTY CHECK WAS BUILT HERE AND MEASURED AND REMOVED. The
99
+ * idea is obvious and the roadmap called for it: mark dirty, then compare
100
+ * a stat-only fingerprint of the tree against the cached one and skip the
101
+ * re-read when nothing changed. It rests on stat being much cheaper than
102
+ * read, and on this corpus it is not — the fingerprint has to `readdir`
103
+ * every directory and `stat` every file, which is nearly everything the
104
+ * scan does apart from the read and the parse.
105
+ *
106
+ * Measured over 501 pages, median of six, against a full rescan:
107
+ *
108
+ * ~1.4 KB pages 28.3 ms vs 26.8 ms 0.95x (slower)
109
+ * ~20 KB pages 28.8 ms vs 27.4 ms 0.95x (slower)
110
+ * ~120 KB pages 39.1 ms vs 45.1 ms 1.15x (faster)
111
+ *
112
+ * Documentation pages are the first two rows. So it is a small regression
113
+ * plus a new class of invalidation bug, in exchange for a win on a corpus
114
+ * nobody has. There is no cheaper correct fingerprint either: statting
115
+ * only directories catches an added or renamed file but not an edited one,
116
+ * which is the common case in a dev server.
117
+ *
118
+ * If this is ever revisited, the thing to change is the *scan*, not the
119
+ * check — patch only the files whose mtime moved and re-derive the nav in
120
+ * memory, which is a different item with a much harder correctness story.
121
+ */
90
122
  invalidate() {
91
123
  cached = null;
92
124
  },
@@ -201,9 +233,10 @@ async function readPage(filePath, name, dirSegments, config) {
201
233
  let data;
202
234
  let content;
203
235
  try {
204
- const parsed = matter(raw, { language: "yaml" });
205
- data = parsed.data;
206
- content = parsed.content;
236
+ const file = new VFile({ value: raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw });
237
+ matter(file, { strip: true });
238
+ data = file.data.matter;
239
+ content = String(file);
207
240
  } catch (err) {
208
241
  const reason = err instanceof Error ? err.message : String(err);
209
242
  throw docsError("invalid-frontmatter", `Could not parse the frontmatter block in ${relativePath}: ${reason}`, { cause: err });
@@ -348,29 +381,6 @@ function isVisibleIn(file, config) {
348
381
  * it. `c++` is the loud sibling: the build aborts with `Unexpected MODIFIER at
349
382
  * 7`, naming an offset into a string the author never wrote and no file.
350
383
  */
351
- const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
352
- /**
353
- * A former URL from `aliases` frontmatter, as a route.
354
- *
355
- * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
356
- * Leading and trailing slashes are tolerated because authors write them, but
357
- * the value is always relative to the base path — an alias of `'/docs/old'` on
358
- * a `/docs` site would produce `/docs/docs/old`.
359
- *
360
- * Shared by both adapters so they agree on which routes exist: an alias is a
361
- * redirect the host installs, so a link to one resolves, and a link that
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.
364
- */
365
- function toAliasRoute(alias, basePath, sourceLabel) {
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)}`;
373
- }
374
384
  /**
375
385
  * ⚠️ `_` AND `.` BOTH, MATCHING `isIgnoredDir` BELOW — which is what this did
376
386
  * NOT do. A leading dot was skipped and a leading underscore was not, so
@@ -394,19 +404,6 @@ function isIgnoredDir(name) {
394
404
  function stripExtension(name) {
395
405
  return name.slice(0, name.length - path.extname(name).length);
396
406
  }
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("/");
409
- }
410
407
  function toHref(basePath, segments) {
411
408
  if (segments.length === 0) return basePath === "" ? "/" : basePath;
412
409
  return `${basePath}/${encodeSegments(segments)}`;
@@ -425,4 +422,4 @@ function humanize(name) {
425
422
  return name.split(/[-_\s]+/).filter((word) => word !== "").map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(" ");
426
423
  }
427
424
  //#endregion
428
- export { createDocsSource, resolveDocsConfig, toAliasRoute };
425
+ export { createDocsSource, resolveDocsConfig };