@waveso/docs 0.1.0 → 0.3.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 (79) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +592 -88
  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 +19 -0
  10. package/dist/docs-error.js +28 -0
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/frontmatter.d.ts +39 -7
  14. package/dist/frontmatter.js +51 -24
  15. package/dist/highlighter.d.ts +2 -2
  16. package/dist/highlighter.js +3 -2
  17. package/dist/map-pooled.d.ts +26 -0
  18. package/dist/map-pooled.js +45 -0
  19. package/dist/meta.d.ts +7 -3
  20. package/dist/meta.js +61 -15
  21. package/dist/next.d.ts +182 -35
  22. package/dist/next.js +177 -49
  23. package/dist/plugins/rehype-capture-toc.js +52 -20
  24. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  25. package/dist/plugins/rehype-code-frame.js +88 -0
  26. package/dist/plugins/rehype-code-language.d.ts +24 -0
  27. package/dist/plugins/rehype-code-language.js +54 -0
  28. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  29. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  30. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  31. package/dist/plugins/rehype-flatten-roots.js +39 -0
  32. package/dist/plugins/remark-doc-links.d.ts +12 -1
  33. package/dist/plugins/remark-doc-links.js +147 -20
  34. package/dist/react/code-runtime.d.ts +14 -0
  35. package/dist/react/code-runtime.js +161 -0
  36. package/dist/react/doc-content.d.ts +39 -2
  37. package/dist/react/doc-content.js +42 -10
  38. package/dist/react/layout.d.ts +44 -0
  39. package/dist/react/layout.js +65 -0
  40. package/dist/react/markdown-components.js +71 -6
  41. package/dist/react/nav.d.ts +28 -0
  42. package/dist/react/nav.js +70 -0
  43. package/dist/react/nearest-scroll-top.d.ts +45 -0
  44. package/dist/react/nearest-scroll-top.js +44 -0
  45. package/dist/react/next-link.d.ts +34 -0
  46. package/dist/react/next-link.js +30 -0
  47. package/dist/react/next-nav.d.ts +11 -0
  48. package/dist/react/next-nav.js +32 -0
  49. package/dist/react/next-search.d.ts +22 -0
  50. package/dist/react/next-search.js +52 -0
  51. package/dist/react/search-dialog.d.ts +35 -7
  52. package/dist/react/search-dialog.js +55 -33
  53. package/dist/react/shell-labels.d.ts +43 -0
  54. package/dist/react/shell-labels.js +27 -0
  55. package/dist/react/sidebar.d.ts +38 -3
  56. package/dist/react/sidebar.js +104 -12
  57. package/dist/react/skip-link.d.ts +1 -9
  58. package/dist/react/skip-link.js +6 -5
  59. package/dist/react/toc.d.ts +12 -4
  60. package/dist/react/toc.js +46 -12
  61. package/dist/react/youtube.d.ts +31 -5
  62. package/dist/react/youtube.js +76 -52
  63. package/dist/render.d.ts +78 -10
  64. package/dist/render.js +137 -54
  65. package/dist/route-path.d.ts +46 -0
  66. package/dist/route-path.js +51 -0
  67. package/dist/search-index.d.ts +22 -21
  68. package/dist/search-index.js +27 -78
  69. package/dist/search-options.d.ts +32 -1
  70. package/dist/search-options.js +66 -3
  71. package/dist/section-boundary.d.ts +17 -0
  72. package/dist/section-boundary.js +43 -0
  73. package/dist/sitemap-limit.d.ts +34 -0
  74. package/dist/sitemap-limit.js +37 -0
  75. package/dist/source.d.ts +12 -22
  76. package/dist/source.js +165 -72
  77. package/dist/styles.css +1117 -125
  78. package/dist/types.d.ts +52 -29
  79. package/package.json +70 -34
@@ -0,0 +1,29 @@
1
+ import { Root } from "hast";
2
+ //#region src/code-frame.d.ts
3
+ /** Marks the `<figure>` a code block is wrapped in. */
4
+ declare const CODE_FRAME_ATTRIBUTE = "data-wave-docs-code";
5
+ /** The copy button inside that figure. */
6
+ declare const CODE_COPY_ATTRIBUTE = "data-wave-docs-copy";
7
+ /**
8
+ * Set on `<html>` by the runtime once its listener is attached.
9
+ *
10
+ * ⚠️ THIS IS WHAT CLOSES THE INERT-MARKUP TRAP, AND IT IS STRUCTURAL RATHER
11
+ * THAN DOCUMENTED. The button is in the HTML whether or not any JavaScript
12
+ * runs — a `renderToStaticMarkup` consumer, a reader with scripts off, anyone
13
+ * rendering the hast by hand. The stylesheet keeps it `visibility: hidden`
14
+ * until this attribute appears, which also takes it out of the tab order, so
15
+ * none of those readers meets a button that silently does nothing or a dead
16
+ * tab stop where a control should be.
17
+ */
18
+ declare const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
19
+ /**
20
+ * Whether a tree contains a code frame.
21
+ *
22
+ * `DocContent` asks before rendering the runtime, so a page with no fences
23
+ * ships zero extra bytes rather than a client component that finds nothing to
24
+ * do. The server has the tree in hand and this is one pass over it, so the
25
+ * check costs nothing the render was not already paying.
26
+ */
27
+ declare function hasCodeFrame(tree: Root): boolean;
28
+ //#endregion
29
+ export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE, hasCodeFrame };
@@ -0,0 +1,41 @@
1
+ //#region src/code-frame.ts
2
+ /** Marks the `<figure>` a code block is wrapped in. */
3
+ const CODE_FRAME_ATTRIBUTE = "data-wave-docs-code";
4
+ /** The copy button inside that figure. */
5
+ const CODE_COPY_ATTRIBUTE = "data-wave-docs-copy";
6
+ /**
7
+ * Set on `<html>` by the runtime once its listener is attached.
8
+ *
9
+ * ⚠️ THIS IS WHAT CLOSES THE INERT-MARKUP TRAP, AND IT IS STRUCTURAL RATHER
10
+ * THAN DOCUMENTED. The button is in the HTML whether or not any JavaScript
11
+ * runs — a `renderToStaticMarkup` consumer, a reader with scripts off, anyone
12
+ * rendering the hast by hand. The stylesheet keeps it `visibility: hidden`
13
+ * until this attribute appears, which also takes it out of the tab order, so
14
+ * none of those readers meets a button that silently does nothing or a dead
15
+ * tab stop where a control should be.
16
+ */
17
+ const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
18
+ /**
19
+ * Whether a tree contains a code frame.
20
+ *
21
+ * `DocContent` asks before rendering the runtime, so a page with no fences
22
+ * ships zero extra bytes rather than a client component that finds nothing to
23
+ * do. The server has the tree in hand and this is one pass over it, so the
24
+ * check costs nothing the render was not already paying.
25
+ */
26
+ function hasCodeFrame(tree) {
27
+ return containsFrame(tree.children);
28
+ }
29
+ function containsFrame(nodes) {
30
+ for (const node of nodes) {
31
+ if (node.type !== "element") continue;
32
+ if (isCodeFrame(node)) return true;
33
+ if (containsFrame(node.children)) return true;
34
+ }
35
+ return false;
36
+ }
37
+ function isCodeFrame(node) {
38
+ return node.properties[CODE_FRAME_ATTRIBUTE] !== void 0;
39
+ }
40
+ //#endregion
41
+ export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE, hasCodeFrame };
@@ -0,0 +1,48 @@
1
+ //#region src/code-meta.d.ts
2
+ /**
3
+ * The bit after the language on a fence: ```` ```ts title="app/page.tsx" ````.
4
+ *
5
+ * Private — deliberately not an entry point. What a consumer sees is the
6
+ * `<figcaption>` it produces; the grammar is ours to extend.
7
+ *
8
+ * ## Read, never mutate
9
+ *
10
+ * `parseCodeMeta` takes a string and returns a value. It does not rewrite
11
+ * `code.data.meta`, and that restraint is the whole design: Shiki forwards the
12
+ * raw meta string to its transformers as `meta.__raw`, which is how
13
+ * `{1,3-5}` line highlighting and `showLineNumbers` will work when those land.
14
+ * A parser that consumed what it recognised would silently disable every
15
+ * feature it had not been taught about yet.
16
+ *
17
+ * ## Why `title=`
18
+ *
19
+ * Not a design question — a survey. Fumadocs, Starlight (via Expressive Code)
20
+ * and Docusaurus all spell it `title="…"`. Nextra's `filename=` and VitePress's
21
+ * `[filename]` are the minority, and `[…]` has nowhere to grow, so adopting it
22
+ * would mean two grammars in one meta string. `title` collides with frontmatter
23
+ * `title` as a word, which one comment fixes; making three of the five nearest
24
+ * comparators' users learn a second spelling does not have a fix.
25
+ *
26
+ * ## Unknown keys are ignored, malformed ones are not
27
+ *
28
+ * Shiki's meta string is an open namespace shared with its own transformers, so
29
+ * an author pasting `twoslash` or `{1,3}` from another site's documentation must
30
+ * not fail the build. But `title=app/page.tsx` — unquoted — is a *malformed*
31
+ * `title`, and ignoring it silently ships a caption reading `app/page.tsx` only
32
+ * up to the first space, or no caption at all. That throws, naming the file.
33
+ */
34
+ /** What a fence's meta string asked for. */
35
+ interface CodeMeta {
36
+ /** The `<figcaption>` text, and part of the copy button's accessible name. */
37
+ title?: string | undefined;
38
+ }
39
+ /**
40
+ * Read {@link CodeMeta} out of a fence's meta string.
41
+ *
42
+ * `path` is only ever used to name the offending document in an error. The
43
+ * alternative — an error saying `title=` was malformed, on a site with four
44
+ * hundred pages — is a grep.
45
+ */
46
+ declare function parseCodeMeta(meta: string | undefined, path: string): CodeMeta;
47
+ //#endregion
48
+ export { CodeMeta, parseCodeMeta };
@@ -0,0 +1,72 @@
1
+ import { docsError } from "./docs-error.js";
2
+ //#region src/code-meta.ts
3
+ /**
4
+ * The bit after the language on a fence: ```` ```ts title="app/page.tsx" ````.
5
+ *
6
+ * Private — deliberately not an entry point. What a consumer sees is the
7
+ * `<figcaption>` it produces; the grammar is ours to extend.
8
+ *
9
+ * ## Read, never mutate
10
+ *
11
+ * `parseCodeMeta` takes a string and returns a value. It does not rewrite
12
+ * `code.data.meta`, and that restraint is the whole design: Shiki forwards the
13
+ * raw meta string to its transformers as `meta.__raw`, which is how
14
+ * `{1,3-5}` line highlighting and `showLineNumbers` will work when those land.
15
+ * A parser that consumed what it recognised would silently disable every
16
+ * feature it had not been taught about yet.
17
+ *
18
+ * ## Why `title=`
19
+ *
20
+ * Not a design question — a survey. Fumadocs, Starlight (via Expressive Code)
21
+ * and Docusaurus all spell it `title="…"`. Nextra's `filename=` and VitePress's
22
+ * `[filename]` are the minority, and `[…]` has nowhere to grow, so adopting it
23
+ * would mean two grammars in one meta string. `title` collides with frontmatter
24
+ * `title` as a word, which one comment fixes; making three of the five nearest
25
+ * comparators' users learn a second spelling does not have a fix.
26
+ *
27
+ * ## Unknown keys are ignored, malformed ones are not
28
+ *
29
+ * Shiki's meta string is an open namespace shared with its own transformers, so
30
+ * an author pasting `twoslash` or `{1,3}` from another site's documentation must
31
+ * not fail the build. But `title=app/page.tsx` — unquoted — is a *malformed*
32
+ * `title`, and ignoring it silently ships a caption reading `app/page.tsx` only
33
+ * up to the first space, or no caption at all. That throws, naming the file.
34
+ */
35
+ /**
36
+ * `title="…"`, anywhere in the string, with the value in double quotes.
37
+ *
38
+ * A filename containing a double quote is inexpressible, and gets no escape
39
+ * mechanism: no real path has one, and an escape grammar is a permanent tax on
40
+ * every reader of this regex to serve a case that does not occur.
41
+ */
42
+ const TITLE = /(^|\s)title="([^"]*)"/;
43
+ /**
44
+ * A `title=` at a word boundary, however it is spelled.
45
+ *
46
+ * Only consulted once {@link TITLE} has already failed, so reaching this means
47
+ * the author wrote a title the one grammar cannot read — unquoted,
48
+ * single-quoted, or opened and never closed. An earlier version excluded
49
+ * anything followed by a quote, which let `title="a.ts` (no closing quote)
50
+ * fall through and produce no caption at all, silently. That is the failure
51
+ * this function exists to make loud.
52
+ */
53
+ const MALFORMED_TITLE = /(^|\s)title=/;
54
+ /**
55
+ * Read {@link CodeMeta} out of a fence's meta string.
56
+ *
57
+ * `path` is only ever used to name the offending document in an error. The
58
+ * alternative — an error saying `title=` was malformed, on a site with four
59
+ * hundred pages — is a grep.
60
+ */
61
+ function parseCodeMeta(meta, path) {
62
+ if (meta === void 0 || meta.trim() === "") return {};
63
+ const matched = TITLE.exec(meta);
64
+ if (matched === null) {
65
+ if (MALFORMED_TITLE.test(meta)) throw docsError("invalid-code-meta", `${path}: a code fence has a malformed \`title\`. Write it as \`title="app/page.tsx"\`, with double quotes — an unquoted title stops at the first space, and a title containing a double quote cannot be expressed. Got: \`${meta.trim()}\``);
66
+ return {};
67
+ }
68
+ const title = matched[2];
69
+ return title === void 0 || title === "" ? {} : { title };
70
+ }
71
+ //#endregion
72
+ export { parseCodeMeta };
@@ -0,0 +1,19 @@
1
+ //#region src/docs-content-id.d.ts
2
+ /**
3
+ * The id of the rendered `<article>`, and the target of the skip link.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`. The documented
6
+ * import is `@waveso/docs/react/skip-link`, which re-exports it; this module
7
+ * exists so `src/next.ts` can have the constant without importing a
8
+ * `'use client'` module for a string.
9
+ *
10
+ * One constant for both halves, and no way to change it. The two files spelled
11
+ * it independently once, and a skip link pointing at an id nothing carries
12
+ * scrolls nowhere and focuses nothing — a failure with no symptom until a
13
+ * keyboard user hits it. `createDocsRoute` used to accept a `contentId` option
14
+ * that broke exactly that pairing, silently, because `SkipLink` had no matching
15
+ * option to follow it with.
16
+ */
17
+ declare const DOCS_CONTENT_ID = "docs-content";
18
+ //#endregion
19
+ export { DOCS_CONTENT_ID };
@@ -0,0 +1,19 @@
1
+ //#region src/docs-content-id.ts
2
+ /**
3
+ * The id of the rendered `<article>`, and the target of the skip link.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`. The documented
6
+ * import is `@waveso/docs/react/skip-link`, which re-exports it; this module
7
+ * exists so `src/next.ts` can have the constant without importing a
8
+ * `'use client'` module for a string.
9
+ *
10
+ * One constant for both halves, and no way to change it. The two files spelled
11
+ * it independently once, and a skip link pointing at an id nothing carries
12
+ * scrolls nowhere and focuses nothing — a failure with no symptom until a
13
+ * keyboard user hits it. `createDocsRoute` used to accept a `contentId` option
14
+ * that broke exactly that pairing, silently, because `SkipLink` had no matching
15
+ * option to follow it with.
16
+ */
17
+ const DOCS_CONTENT_ID = "docs-content";
18
+ //#endregion
19
+ export { DOCS_CONTENT_ID };
@@ -0,0 +1,19 @@
1
+ import { DocsError, DocsErrorCode } from "./errors.js";
2
+ //#region src/docs-error.d.ts
3
+ /**
4
+ * Build an error that names this package and says what class of thing failed.
5
+ *
6
+ * The message is passed through untouched apart from the prefix, which is added
7
+ * only when absent — the wording at each throw site is the part a human reads,
8
+ * and several of them took real effort to get right.
9
+ *
10
+ * `code` is attached non-enumerably so it does not appear in `JSON.stringify`
11
+ * or a spread, which keeps error objects looking exactly as they did while
12
+ * still being branchable.
13
+ *
14
+ * The stack is left alone. Flattening it would hide the `dist/` frames a
15
+ * consumer does not care about, and also the ones a maintainer needs.
16
+ */
17
+ declare function docsError(code: DocsErrorCode, message: string, options?: ErrorOptions): DocsError;
18
+ //#endregion
19
+ export { docsError };
@@ -0,0 +1,28 @@
1
+ import { DOCS_ERROR_PREFIX } from "./errors.js";
2
+ //#region src/docs-error.ts
3
+ /**
4
+ * Build an error that names this package and says what class of thing failed.
5
+ *
6
+ * The message is passed through untouched apart from the prefix, which is added
7
+ * only when absent — the wording at each throw site is the part a human reads,
8
+ * and several of them took real effort to get right.
9
+ *
10
+ * `code` is attached non-enumerably so it does not appear in `JSON.stringify`
11
+ * or a spread, which keeps error objects looking exactly as they did while
12
+ * still being branchable.
13
+ *
14
+ * The stack is left alone. Flattening it would hide the `dist/` frames a
15
+ * consumer does not care about, and also the ones a maintainer needs.
16
+ */
17
+ function docsError(code, message, options) {
18
+ const error = new Error(message.startsWith("@waveso/docs: ") ? message : `${DOCS_ERROR_PREFIX}${message}`, options);
19
+ Object.defineProperty(error, "code", {
20
+ value: code,
21
+ enumerable: false,
22
+ writable: false,
23
+ configurable: true
24
+ });
25
+ return error;
26
+ }
27
+ //#endregion
28
+ export { docsError };
@@ -0,0 +1,94 @@
1
+ //#region src/errors.d.ts
2
+ /**
3
+ * The error taxonomy, as public API.
4
+ *
5
+ * Every failure this package raises is an `Error` whose message names the
6
+ * package and which carries a `code` off this union. That exists so a host can
7
+ * branch — downgrade broken-link failures in a preview build, say, or report
8
+ * `invalid-frontmatter` differently from `missing-peer` — and until this
9
+ * module was an entry point there was no supported way to do it. The taxonomy
10
+ * said "branch on this" while the module said "you cannot import me", and the
11
+ * two paragraphs contradicted each other for nineteen codes and forty-five
12
+ * call sites.
13
+ *
14
+ * ```ts
15
+ * import { isDocsError } from '@waveso/docs/errors';
16
+ *
17
+ * try {
18
+ * await docs.renderAll();
19
+ * } catch (error) {
20
+ * if (isDocsError(error) && error.code === 'draft-link') {
21
+ * console.warn(error.message);
22
+ * } else {
23
+ * throw error;
24
+ * }
25
+ * }
26
+ * ```
27
+ *
28
+ * No class is exported, and that is deliberate: `instanceof` against a copy of
29
+ * this module resolved twice — a monorepo with two versions installed, a
30
+ * bundler that duplicates it — silently answers `false`. A string `code` and a
31
+ * structural guard have no such failure mode.
32
+ */
33
+ /** Prefix every message carries, so a stack trace names the culprit package. */
34
+ declare const DOCS_ERROR_PREFIX = "@waveso/docs: ";
35
+ /**
36
+ * What went wrong, as something a consumer can branch on.
37
+ *
38
+ * Before this existed, every failure was a bare `Error` and roughly half the
39
+ * messages omitted the package prefix, so a host wanting to downgrade (say)
40
+ * broken-link failures in dev had nothing to test but the message text — and
41
+ * `message.startsWith('@waveso/docs:')` was not even a reliable filter.
42
+ */
43
+ type DocsErrorCode =
44
+ /** A link resolves to a route no published page owns. */
45
+ 'broken-link' |
46
+ /** A link resolves to a page that exists but is `draft: true`. */
47
+ 'draft-link' |
48
+ /** A link resolves to an alias, which is a redirect and not a page. */
49
+ 'alias-link' |
50
+ /** An `aliases` entry is empty, escapes the root, or is not URL-safe. */
51
+ 'invalid-alias' |
52
+ /** Two pages claim one alias, or an alias shadows a real route. */
53
+ 'alias-collision' |
54
+ /** Two files resolve to the same route. */
55
+ 'route-collision' |
56
+ /** A page's frontmatter is missing, malformed, or rejected by the schema. */
57
+ 'invalid-frontmatter' |
58
+ /** A `meta.json` is malformed, or names something that is not there. */
59
+ 'invalid-meta' |
60
+ /** An option passed to this package cannot be used as given. */
61
+ 'invalid-config' |
62
+ /** `contentDir` does not point at a readable directory. */
63
+ 'missing-content-dir' |
64
+ /** A markdown page is reachable only through a broken symbolic link. */
65
+ 'broken-symlink' |
66
+ /** An `imageResolver` returned an unusable shape, threw, or was needed. */
67
+ 'invalid-image' |
68
+ /** A theme name outside the supported set. */
69
+ 'unknown-theme' |
70
+ /** A fence language outside the loaded set. */
71
+ 'unknown-language' |
72
+ /** An optional peer (`next`) is absent or not the expected shape. */
73
+ 'missing-peer' |
74
+ /** The search index could not be fetched or parsed. */
75
+ 'search-index-unavailable' |
76
+ /** The search-index route ran at request time instead of being prerendered. */
77
+ 'search-index-dynamic' |
78
+ /** A code fence's meta string has a `title` that cannot be read. */
79
+ 'invalid-code-meta' |
80
+ /** A plugin ran without the context this package always supplies. */
81
+ 'internal';
82
+ /** An {@link Error} carrying a {@link DocsErrorCode}. */
83
+ interface DocsError extends Error {
84
+ readonly code: DocsErrorCode;
85
+ }
86
+ /**
87
+ * Narrow an unknown caught value to one of this package's errors.
88
+ *
89
+ * Checks the prefix as well as the shape, so an unrelated `Error` that happens
90
+ * to carry a `code` — Node's own `ENOENT`, for one — is not mistaken for ours.
91
+ */
92
+ declare function isDocsError(value: unknown): value is DocsError;
93
+ //#endregion
94
+ export { DOCS_ERROR_PREFIX, DocsError, DocsErrorCode, isDocsError };
package/dist/errors.js ADDED
@@ -0,0 +1,45 @@
1
+ //#region src/errors.ts
2
+ /**
3
+ * The error taxonomy, as public API.
4
+ *
5
+ * Every failure this package raises is an `Error` whose message names the
6
+ * package and which carries a `code` off this union. That exists so a host can
7
+ * branch — downgrade broken-link failures in a preview build, say, or report
8
+ * `invalid-frontmatter` differently from `missing-peer` — and until this
9
+ * module was an entry point there was no supported way to do it. The taxonomy
10
+ * said "branch on this" while the module said "you cannot import me", and the
11
+ * two paragraphs contradicted each other for nineteen codes and forty-five
12
+ * call sites.
13
+ *
14
+ * ```ts
15
+ * import { isDocsError } from '@waveso/docs/errors';
16
+ *
17
+ * try {
18
+ * await docs.renderAll();
19
+ * } catch (error) {
20
+ * if (isDocsError(error) && error.code === 'draft-link') {
21
+ * console.warn(error.message);
22
+ * } else {
23
+ * throw error;
24
+ * }
25
+ * }
26
+ * ```
27
+ *
28
+ * No class is exported, and that is deliberate: `instanceof` against a copy of
29
+ * this module resolved twice — a monorepo with two versions installed, a
30
+ * bundler that duplicates it — silently answers `false`. A string `code` and a
31
+ * structural guard have no such failure mode.
32
+ */
33
+ /** Prefix every message carries, so a stack trace names the culprit package. */
34
+ const DOCS_ERROR_PREFIX = "@waveso/docs: ";
35
+ /**
36
+ * Narrow an unknown caught value to one of this package's errors.
37
+ *
38
+ * Checks the prefix as well as the shape, so an unrelated `Error` that happens
39
+ * to carry a `code` — Node's own `ENOENT`, for one — is not mistaken for ours.
40
+ */
41
+ function isDocsError(value) {
42
+ return value instanceof Error && typeof value.code === "string" && value.message.startsWith("@waveso/docs: ");
43
+ }
44
+ //#endregion
45
+ export { DOCS_ERROR_PREFIX, isDocsError };
@@ -6,10 +6,10 @@ import { StandardSchemaV1 } from "@standard-schema/spec";
6
6
  * The frontmatter fields the package itself understands, as a Zod schema.
7
7
  *
8
8
  * Optional fields use `.exactOptional()` rather than `.optional()` so the
9
- * inferred type is `{ description?: string }` and not
10
- * `{ description?: string | undefined }`the latter is not assignable to
11
- * {@link DocFrontmatter} under `exactOptionalPropertyTypes`, and an explicit
12
- * `undefined` cannot come out of YAML anyway.
9
+ * inferred type is `{ description?: string }`: absent stays absent, which is
10
+ * the only thing YAML can expressthere is no way to author an explicit
11
+ * `undefined` in a frontmatter block. A consumer's schema may still use plain
12
+ * `.optional()`; {@link DocFrontmatter} accepts both.
13
13
  *
14
14
  * `description` is deliberately not length-capped. The 155–160 character
15
15
  * advice for `<meta name="description">` is a pixel-width heuristic about
@@ -39,6 +39,17 @@ declare const docFrontmatterSchema: z.ZodObject<{
39
39
  * `raw` is whatever the YAML parser produced — `unknown` by construction, so
40
40
  * every field is checked rather than trusted.
41
41
  *
42
+ * The six fields this package reads are always parsed from the raw block by
43
+ * {@link docFrontmatterSchema} and laid back over the result, so a custom
44
+ * `schema` can only ever *add* fields. It cannot drop `draft`, `aliases`,
45
+ * `order`, `label`, `description` or `title` — which a bare
46
+ * `z.object({ title, audience })` silently does — and it cannot redefine them
47
+ * into a shape the sidebar, the redirects and the sitemap do not expect. The
48
+ * price is that a `.default()`, `.transform()` or `.coerce` aimed at one of the
49
+ * six is not honoured: the YAML wins. The return type intersects
50
+ * {@link DocFrontmatter} for the same reason — the built-ins are there whether
51
+ * or not the schema declared them.
52
+ *
42
53
  * Async because `~standard.validate` is allowed to return a promise and some
43
54
  * validators do (any schema with an async refinement). The source layer is
44
55
  * already async, so awaiting here costs nothing and refusing promises would
@@ -47,9 +58,30 @@ declare const docFrontmatterSchema: z.ZodObject<{
47
58
  * @param raw - Parsed YAML frontmatter block.
48
59
  * @param filePath - Path reported in the error. Pass the path the author
49
60
  * would recognise (relative to the content root), not an absolute one.
50
- * @param schema - Optional replacement schema, normally
61
+ */
62
+ declare function parseFrontmatter(raw: unknown, filePath: string): Promise<DocFrontmatter>;
63
+ /**
64
+ * Validate one file's frontmatter against `schema`, or throw an error that
65
+ * names the file. See the two-argument overload for the contract.
66
+ *
67
+ * `TFrontmatter` comes from the schema and must never be named at the call
68
+ * site. That is what the two overloads buy: `parseFrontmatter<Mine>(raw, file)`
69
+ * with no schema is a compile error, where before it type-checked and then lied
70
+ * — nothing validated `Mine`, so its fields were `undefined` at runtime while
71
+ * typed as present.
72
+ *
73
+ * `undefined` is accepted here rather than the schema being optional so that a
74
+ * `DocsConfig<T>`'s own optional schema passes straight through. An earlier
75
+ * attempt made it required and broke exactly that, at `createDocsRoute` and at
76
+ * any consumer holding a typed config.
77
+ *
78
+ * @param raw - Parsed YAML frontmatter block.
79
+ * @param filePath - Path reported in the error. Pass the path the author
80
+ * would recognise (relative to the content root), not an absolute one.
81
+ * @param schema - Replacement schema, normally
51
82
  * `docFrontmatterSchema.extend(...)`. Any Standard Schema validator works.
83
+ * `undefined` selects {@link docFrontmatterSchema}.
52
84
  */
53
- declare function parseFrontmatter<TFrontmatter extends DocFrontmatter = DocFrontmatter>(raw: unknown, filePath: string, schema?: StandardSchemaV1<unknown, TFrontmatter>): Promise<TFrontmatter>;
85
+ declare function parseFrontmatter<TFrontmatter extends DocFrontmatter>(raw: unknown, filePath: string, schema: StandardSchemaV1<unknown, TFrontmatter> | undefined): Promise<TFrontmatter & DocFrontmatter>;
54
86
  //#endregion
55
- export { docFrontmatterSchema, parseFrontmatter };
87
+ export { docFrontmatterSchema, parseFrontmatter, z };
@@ -1,13 +1,14 @@
1
+ import { docsError } from "./docs-error.js";
1
2
  import { z } from "zod";
2
3
  //#region src/frontmatter.ts
3
4
  /**
4
5
  * The frontmatter fields the package itself understands, as a Zod schema.
5
6
  *
6
7
  * Optional fields use `.exactOptional()` rather than `.optional()` so the
7
- * inferred type is `{ description?: string }` and not
8
- * `{ description?: string | undefined }`the latter is not assignable to
9
- * {@link DocFrontmatter} under `exactOptionalPropertyTypes`, and an explicit
10
- * `undefined` cannot come out of YAML anyway.
8
+ * inferred type is `{ description?: string }`: absent stays absent, which is
9
+ * the only thing YAML can expressthere is no way to author an explicit
10
+ * `undefined` in a frontmatter block. A consumer's schema may still use plain
11
+ * `.optional()`; {@link DocFrontmatter} accepts both.
11
12
  *
12
13
  * `description` is deliberately not length-capped. The 155–160 character
13
14
  * advice for `<meta name="description">` is a pixel-width heuristic about
@@ -32,34 +33,60 @@ const docFrontmatterSchema = z.object({
32
33
  order: z.number().exactOptional()
33
34
  });
34
35
  /**
35
- * Validate one file's frontmatter, or throw an error that names the file.
36
+ * The package's own fields, every one of them optional, for the overlay pass
37
+ * in {@link parseFrontmatter}.
36
38
  *
37
- * `raw` is whatever the YAML parser produced `unknown` by construction, so
38
- * every field is checked rather than trusted.
39
- *
40
- * Async because `~standard.validate` is allowed to return a promise and some
41
- * validators do (any schema with an async refinement). The source layer is
42
- * already async, so awaiting here costs nothing and refusing promises would
43
- * have made a documented half of the spec silently unsupported.
44
- *
45
- * @param raw - Parsed YAML frontmatter block.
46
- * @param filePath - Path reported in the error. Pass the path the author
47
- * would recognise (relative to the content root), not an absolute one.
48
- * @param schema - Optional replacement schema, normally
49
- * `docFrontmatterSchema.extend(...)`. Any Standard Schema validator works.
39
+ * `title` is optional here too, even though the package requires it: a custom
40
+ * schema may legitimately produce a title the YAML does not carry (a
41
+ * `.default()`), and demanding one of the raw block would fail that file. The
42
+ * check after the merge reads the merged title instead, so both routes to a
43
+ * title are accepted and no route to a missing one is.
50
44
  */
45
+ const builtInFields = docFrontmatterSchema.partial();
51
46
  async function parseFrontmatter(raw, filePath, schema) {
52
47
  const active = schema ?? docFrontmatterSchema;
53
48
  let result;
54
49
  try {
55
50
  result = await active["~standard"].validate(raw ?? {});
56
51
  } catch (error) {
57
- throw new Error(`Invalid frontmatter in ${filePath}: the schema threw while validating it. This is a bug in the schema, not in the YAML — check any \`refine\`/\`transform\`/\`check\` it declares.`, { cause: error });
52
+ throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}: the schema threw while validating it. This is a bug in the schema, not in the YAML — check any \`refine\`/\`transform\`/\`check\` it declares.`, { cause: error });
58
53
  }
59
- if (result.issues === void 0) return result.value;
60
- if (result.issues.length === 0) throw new Error(`Invalid frontmatter in ${filePath}: the schema rejected it but reported no issues, so there is nothing to act on. This is a bug in the schema.`);
61
- const details = result.issues.map((issue) => ` - ${formatIssuePath(issue.path)}: ${issue.message}`).join("\n");
62
- throw new Error(`Invalid frontmatter in ${filePath}:\n${details}\n${schema === void 0 ? "Fix the YAML block at the top of that file." : "Fix the YAML block at the top of that file, or the `frontmatterSchema` in your docs config."}`);
54
+ if (result.issues === void 0) return active === docFrontmatterSchema ? result.value : applyBuiltIns(result.value, raw, filePath);
55
+ if (result.issues.length === 0) throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}: the schema rejected it but reported no issues, so there is nothing to act on. This is a bug in the schema.`);
56
+ const source = schema === void 0 ? "Fix the YAML block at the top of that file." : "Fix the YAML block at the top of that file, or the `frontmatterSchema` in your docs config.";
57
+ throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}:\n${formatIssues(result.issues)}\n${source}`);
58
+ }
59
+ /**
60
+ * Re-read the package's own fields from the raw block and lay them back over a
61
+ * custom schema's output.
62
+ *
63
+ * Without this, the guarantee that a `frontmatterSchema` keeps the built-ins
64
+ * rests on `TFrontmatter extends DocFrontmatter` — which constrains `title` and
65
+ * nothing else, because the other five fields are optional. So
66
+ * `z.object({ title, audience })`, written from scratch instead of as
67
+ * `docFrontmatterSchema.extend(...)`, type-checks and then strips `draft`,
68
+ * `aliases`, `order` and `label` on the way through. The package reads the
69
+ * absence as "not a draft, no redirects, no order": an unpublished page ships,
70
+ * is linked from the sidebar and is submitted to Google in the sitemap, and
71
+ * every renamed page 404s. `tsc` reports nothing, at either end.
72
+ *
73
+ * The overlay runs after the custom schema rather than instead of it, so a
74
+ * stricter rule on a built-in (`title: z.string().max(60)`) still fails the
75
+ * build — it just cannot change the value the package then reads.
76
+ */
77
+ async function applyBuiltIns(value, raw, filePath) {
78
+ const result = await builtInFields["~standard"].validate(raw ?? {});
79
+ if (result.issues !== void 0) throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}:\n${formatIssues(result.issues)}\n@waveso/docs reads these fields itself, so they are validated even when your \`frontmatterSchema\` does not declare them. Fix the YAML block at the top of that file.`);
80
+ const merged = {
81
+ ...value,
82
+ ...result.value
83
+ };
84
+ if (typeof merged.title !== "string" || merged.title.length === 0) throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}: no \`title\`. Every page needs one — it drives the \`<h1>\` fallback, \`<title>\`, the sidebar and search. Add \`title:\` to the YAML block at the top of that file, or give your \`frontmatterSchema\` a title it can supply.`);
85
+ return merged;
86
+ }
87
+ /** Render a validator's issues as the bullet list under the file name. */
88
+ function formatIssues(issues) {
89
+ return issues.map((issue) => ` - ${formatIssuePath(issue.path)}: ${issue.message}`).join("\n");
63
90
  }
64
91
  /**
65
92
  * Render an issue path as `aliases[0]` / `title`, never as `""`.
@@ -77,4 +104,4 @@ function formatIssuePath(path) {
77
104
  }, "");
78
105
  }
79
106
  //#endregion
80
- export { docFrontmatterSchema, parseFrontmatter };
107
+ export { docFrontmatterSchema, parseFrontmatter, z };
@@ -81,9 +81,9 @@ interface DocsHighlighterOptions {
81
81
  * than a build-time throw. The runtime check below stays for JavaScript
82
82
  * callers and for values that arrive from JSON config.
83
83
  */
84
- langs?: readonly DocsLang[];
84
+ langs?: readonly DocsLang[] | undefined;
85
85
  /** Theme pair. Defaults to {@link DEFAULT_DOCS_THEMES}. */
86
- themes?: DocsThemes;
86
+ themes?: DocsThemes | undefined;
87
87
  }
88
88
  /**
89
89
  * Create (or reuse) the process-wide highlighter for a given option set.
@@ -1,3 +1,4 @@
1
+ import { docsError } from "./docs-error.js";
1
2
  import { createHighlighterCore } from "shiki/core";
2
3
  import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
3
4
  //#region src/highlighter.ts
@@ -155,11 +156,11 @@ function isDocsTheme(name) {
155
156
  function createDocsHighlighter(options = {}) {
156
157
  const themes = options.themes ?? DEFAULT_DOCS_THEMES;
157
158
  const requested = options.langs ?? DEFAULT_DOCS_LANGS;
158
- for (const theme of [themes.light, themes.dark]) if (!isDocsTheme(theme)) throw new Error(`@waveso/docs: unknown Shiki theme '${theme}'. Supported themes: ${Object.keys(THEME_LOADERS).sort().join(", ")}. To use another theme, pass your own highlighter to createDocsRenderer().`);
159
+ for (const theme of [themes.light, themes.dark]) if (!isDocsTheme(theme)) throw docsError("unknown-theme", `@waveso/docs: unknown Shiki theme '${theme}'. Supported themes: ${Object.keys(THEME_LOADERS).sort().join(", ")}. To use another theme, pass your own highlighter to createDocsRenderer().`);
159
160
  const langs = [...new Set(requested)].sort();
160
161
  const loaders = /* @__PURE__ */ new Set();
161
162
  for (const lang of langs) {
162
- if (!isDocsLang(lang)) throw new Error(`@waveso/docs: unknown code language '${lang}'. Supported languages: ${Object.keys(LANG_LOADERS).sort().join(", ")}. To use another grammar, pass your own highlighter to createDocsRenderer().`);
163
+ if (!isDocsLang(lang)) throw docsError("unknown-language", `@waveso/docs: unknown code language '${lang}'. Supported languages: ${Object.keys(LANG_LOADERS).sort().join(", ")}. To use another grammar, pass your own highlighter to createDocsRenderer().`);
163
164
  loaders.add(LANG_LOADERS[lang]);
164
165
  }
165
166
  const key = JSON.stringify({