@waveso/docs 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +518 -0
  3. package/dist/frontmatter.d.ts +55 -0
  4. package/dist/frontmatter.js +80 -0
  5. package/dist/highlighter.d.ts +99 -0
  6. package/dist/highlighter.js +183 -0
  7. package/dist/meta.d.ts +75 -0
  8. package/dist/meta.js +183 -0
  9. package/dist/next.d.ts +256 -0
  10. package/dist/next.js +365 -0
  11. package/dist/plugins/rehype-capture-toc.d.ts +18 -0
  12. package/dist/plugins/rehype-capture-toc.js +69 -0
  13. package/dist/plugins/remark-doc-links.d.ts +63 -0
  14. package/dist/plugins/remark-doc-links.js +122 -0
  15. package/dist/plugins/remark-unwrap-images.d.ts +11 -0
  16. package/dist/plugins/remark-unwrap-images.js +25 -0
  17. package/dist/plugins/remark-youtube.d.ts +22 -0
  18. package/dist/plugins/remark-youtube.js +84 -0
  19. package/dist/react/callout.d.ts +37 -0
  20. package/dist/react/callout.js +113 -0
  21. package/dist/react/doc-content.d.ts +29 -0
  22. package/dist/react/doc-content.js +30 -0
  23. package/dist/react/markdown-components.d.ts +84 -0
  24. package/dist/react/markdown-components.js +122 -0
  25. package/dist/react/search-dialog.d.ts +41 -0
  26. package/dist/react/search-dialog.js +404 -0
  27. package/dist/react/sidebar.d.ts +29 -0
  28. package/dist/react/sidebar.js +196 -0
  29. package/dist/react/skip-link.d.ts +37 -0
  30. package/dist/react/skip-link.js +37 -0
  31. package/dist/react/toc.d.ts +35 -0
  32. package/dist/react/toc.js +87 -0
  33. package/dist/react/youtube.d.ts +27 -0
  34. package/dist/react/youtube.js +75 -0
  35. package/dist/render.d.ts +72 -0
  36. package/dist/render.js +279 -0
  37. package/dist/search-index.d.ts +51 -0
  38. package/dist/search-index.js +274 -0
  39. package/dist/search-options.d.ts +18 -0
  40. package/dist/search-options.js +40 -0
  41. package/dist/source.d.ts +67 -0
  42. package/dist/source.js +332 -0
  43. package/dist/styles.css +1033 -0
  44. package/dist/types.d.ts +334 -0
  45. package/dist/types.js +0 -0
  46. package/package.json +166 -0
@@ -0,0 +1,99 @@
1
+ import { createHighlighterCore } from "shiki/core";
2
+ //#region src/highlighter.d.ts
3
+ /** A ready-to-use Shiki highlighter carrying only the docs grammars. */
4
+ type DocsHighlighter = Awaited<ReturnType<typeof createHighlighterCore>>;
5
+ declare const LANG_LOADERS: {
6
+ typescript: () => Promise<typeof import("@shikijs/langs/typescript")>;
7
+ ts: () => Promise<typeof import("@shikijs/langs/typescript")>;
8
+ tsx: () => Promise<typeof import("@shikijs/langs/tsx")>;
9
+ javascript: () => Promise<typeof import("@shikijs/langs/javascript")>;
10
+ js: () => Promise<typeof import("@shikijs/langs/javascript")>;
11
+ jsx: () => Promise<typeof import("@shikijs/langs/jsx")>;
12
+ json: () => Promise<typeof import("@shikijs/langs/json")>;
13
+ shellscript: () => Promise<typeof import("@shikijs/langs/shellscript")>;
14
+ bash: () => Promise<typeof import("@shikijs/langs/shellscript")>;
15
+ sh: () => Promise<typeof import("@shikijs/langs/shellscript")>;
16
+ shell: () => Promise<typeof import("@shikijs/langs/shellscript")>;
17
+ zsh: () => Promise<typeof import("@shikijs/langs/shellscript")>;
18
+ css: () => Promise<typeof import("@shikijs/langs/css")>;
19
+ html: () => Promise<typeof import("@shikijs/langs/html")>;
20
+ markdown: () => Promise<typeof import("@shikijs/langs/markdown")>;
21
+ md: () => Promise<typeof import("@shikijs/langs/markdown")>;
22
+ yaml: () => Promise<typeof import("@shikijs/langs/yaml")>;
23
+ yml: () => Promise<typeof import("@shikijs/langs/yaml")>;
24
+ diff: () => Promise<typeof import("@shikijs/langs/diff")>;
25
+ sql: () => Promise<typeof import("@shikijs/langs/sql")>;
26
+ python: () => Promise<typeof import("@shikijs/langs/python")>;
27
+ py: () => Promise<typeof import("@shikijs/langs/python")>;
28
+ go: () => Promise<typeof import("@shikijs/langs/go")>;
29
+ rust: () => Promise<typeof import("@shikijs/langs/rust")>;
30
+ rs: () => Promise<typeof import("@shikijs/langs/rust")>;
31
+ prisma: () => Promise<typeof import("@shikijs/langs/prisma")>;
32
+ ini: () => import("shiki").Awaitable<import("shiki").MaybeModule<import("shiki").MaybeArray<import("shiki").LanguageRegistration>>>;
33
+ cfg: () => import("shiki").Awaitable<import("shiki").MaybeModule<import("shiki").MaybeArray<import("shiki").LanguageRegistration>>>;
34
+ conf: () => import("shiki").Awaitable<import("shiki").MaybeModule<import("shiki").MaybeArray<import("shiki").LanguageRegistration>>>;
35
+ properties: () => import("shiki").Awaitable<import("shiki").MaybeModule<import("shiki").MaybeArray<import("shiki").LanguageRegistration>>>;
36
+ toml: () => Promise<typeof import("@shikijs/langs/toml")>;
37
+ };
38
+ /** Theme loaders. Same static-import constraint as {@link LANG_LOADERS}. */
39
+ declare const THEME_LOADERS: {
40
+ 'github-light': () => Promise<typeof import("@shikijs/themes/github-light")>;
41
+ 'github-dark': () => Promise<typeof import("@shikijs/themes/github-dark")>;
42
+ 'github-light-default': () => Promise<typeof import("@shikijs/themes/github-light-default")>;
43
+ 'github-dark-default': () => Promise<typeof import("@shikijs/themes/github-dark-default")>;
44
+ 'vitesse-light': () => Promise<typeof import("@shikijs/themes/vitesse-light")>;
45
+ 'vitesse-dark': () => Promise<typeof import("@shikijs/themes/vitesse-dark")>;
46
+ 'min-light': () => Promise<typeof import("@shikijs/themes/min-light")>;
47
+ 'min-dark': () => Promise<typeof import("@shikijs/themes/min-dark")>;
48
+ 'one-light': () => Promise<typeof import("@shikijs/themes/one-light")>;
49
+ 'one-dark-pro': () => Promise<typeof import("@shikijs/themes/one-dark-pro")>;
50
+ 'catppuccin-latte': () => Promise<typeof import("@shikijs/themes/catppuccin-latte")>;
51
+ 'catppuccin-mocha': () => Promise<typeof import("@shikijs/themes/catppuccin-mocha")>;
52
+ nord: () => Promise<typeof import("@shikijs/themes/nord")>;
53
+ };
54
+ /** A grammar name accepted by {@link createDocsHighlighter}. */
55
+ type DocsLang = keyof typeof LANG_LOADERS;
56
+ /** A theme name accepted by {@link createDocsHighlighter}. */
57
+ type DocsTheme = keyof typeof THEME_LOADERS;
58
+ /**
59
+ * The light/dark pair. Two themes rather than one is what makes Shiki emit
60
+ * `--shiki-light` / `--shiki-dark` CSS variables, which is how the stylesheet
61
+ * switches colour scheme without shipping a second copy of every token.
62
+ */
63
+ type DocsThemes = {
64
+ light: DocsTheme;
65
+ dark: DocsTheme;
66
+ };
67
+ /**
68
+ * Languages loaded when none are configured: what technical documentation
69
+ * actually contains. Anything outside this list falls back to plain text
70
+ * rather than throwing — see {@link createDocsHighlighter}.
71
+ */
72
+ declare const DEFAULT_DOCS_LANGS: readonly DocsLang[];
73
+ /** Default theme pair. */
74
+ declare const DEFAULT_DOCS_THEMES: DocsThemes;
75
+ interface DocsHighlighterOptions {
76
+ /**
77
+ * Grammars to load. Names or aliases (`'ts'`, `'bash'`). Defaults to
78
+ * {@link DEFAULT_DOCS_LANGS}.
79
+ *
80
+ * Narrowed to {@link DocsLang} so `'typescrpt'` is a compile error rather
81
+ * than a build-time throw. The runtime check below stays for JavaScript
82
+ * callers and for values that arrive from JSON config.
83
+ */
84
+ langs?: readonly DocsLang[];
85
+ /** Theme pair. Defaults to {@link DEFAULT_DOCS_THEMES}. */
86
+ themes?: DocsThemes;
87
+ }
88
+ /**
89
+ * Create (or reuse) the process-wide highlighter for a given option set.
90
+ *
91
+ * Unknown language and theme names throw rather than degrading: the set is
92
+ * fixed at build time, so a typo in config is a build bug, and silently
93
+ * shipping unhighlighted code is exactly the failure this package exists to
94
+ * avoid. If you need a grammar outside the curated set, build your own
95
+ * `createHighlighterCore` and hand it to `createDocsRenderer({ highlighter })`.
96
+ */
97
+ declare function createDocsHighlighter(options?: DocsHighlighterOptions): Promise<DocsHighlighter>;
98
+ //#endregion
99
+ export { DEFAULT_DOCS_LANGS, DEFAULT_DOCS_THEMES, DocsHighlighter, DocsHighlighterOptions, DocsLang, DocsTheme, DocsThemes, createDocsHighlighter };
@@ -0,0 +1,183 @@
1
+ import { createHighlighterCore } from "shiki/core";
2
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
3
+ //#region src/highlighter.ts
4
+ /**
5
+ * The syntax highlighter used for documentation code blocks.
6
+ *
7
+ * Deliberately fine-grained: `shiki/core` plus one static import per grammar
8
+ * and per theme. The bundled entry points (`shiki`, `@shikijs/rehype`) pull all
9
+ * 346 grammars — roughly 11MB and four seconds per build worker — for a docs
10
+ * site that realistically uses a dozen. Nothing here reaches the browser
11
+ * either way; the cost is build time, and it is worth avoiding.
12
+ *
13
+ * The regex engine is the JavaScript one, so no WASM is loaded and the module
14
+ * works unchanged in a Next.js build worker or a plain Node script.
15
+ */
16
+ /**
17
+ * Grammar loaders, keyed by every name an author might reasonably write in a
18
+ * fence. Shiki resolves its own aliases once a grammar is loaded; the extra
19
+ * keys exist so `langs: ['ts']` in user config does not fail the lookup below.
20
+ *
21
+ * Every entry is a static `import()` specifier: that is what lets a bundler
22
+ * see the dependency without pulling the full bundle.
23
+ */
24
+ /**
25
+ * The `ini` grammar, with `cfg` and `conf` registered as aliases of it.
26
+ *
27
+ * ⚠️ SHIKI RESOLVES A FENCE AGAINST A GRAMMAR'S OWN ALIASES, not against the
28
+ * keys of {@link LANG_LOADERS}. `ini` ships exactly one alias, `properties`, so
29
+ * ```cfg threw `Language 'cfg' not found` and `fallbackLanguage` rendered the
30
+ * block as plain text. Loading the grammar under a `cfg` key would not have
31
+ * helped — the lookup that fails is Shiki's, not ours.
32
+ *
33
+ * It matters because the fence an author types follows the filename. A FiveM
34
+ * docs site is mostly `server.cfg`, and nobody writes ```ini above a file
35
+ * called `server.cfg`.
36
+ *
37
+ * One function object, shared by every key that wants it: the dedup below is by
38
+ * loader identity, so `langs: ['ini', 'cfg']` still loads the grammar once.
39
+ */
40
+ const loadIni = async () => {
41
+ const loaded = (await import("@shikijs/langs/ini")).default;
42
+ return (Array.isArray(loaded) ? loaded : [loaded]).map((grammar) => grammar.name === "ini" ? {
43
+ ...grammar,
44
+ aliases: [
45
+ ...grammar.aliases ?? [],
46
+ "cfg",
47
+ "conf"
48
+ ]
49
+ } : grammar);
50
+ };
51
+ const LANG_LOADERS = {
52
+ typescript: () => import("@shikijs/langs/typescript"),
53
+ ts: () => import("@shikijs/langs/typescript"),
54
+ tsx: () => import("@shikijs/langs/tsx"),
55
+ javascript: () => import("@shikijs/langs/javascript"),
56
+ js: () => import("@shikijs/langs/javascript"),
57
+ jsx: () => import("@shikijs/langs/jsx"),
58
+ json: () => import("@shikijs/langs/json"),
59
+ shellscript: () => import("@shikijs/langs/shellscript"),
60
+ bash: () => import("@shikijs/langs/shellscript"),
61
+ sh: () => import("@shikijs/langs/shellscript"),
62
+ shell: () => import("@shikijs/langs/shellscript"),
63
+ zsh: () => import("@shikijs/langs/shellscript"),
64
+ css: () => import("@shikijs/langs/css"),
65
+ html: () => import("@shikijs/langs/html"),
66
+ markdown: () => import("@shikijs/langs/markdown"),
67
+ md: () => import("@shikijs/langs/markdown"),
68
+ yaml: () => import("@shikijs/langs/yaml"),
69
+ yml: () => import("@shikijs/langs/yaml"),
70
+ diff: () => import("@shikijs/langs/diff"),
71
+ sql: () => import("@shikijs/langs/sql"),
72
+ python: () => import("@shikijs/langs/python"),
73
+ py: () => import("@shikijs/langs/python"),
74
+ go: () => import("@shikijs/langs/go"),
75
+ rust: () => import("@shikijs/langs/rust"),
76
+ rs: () => import("@shikijs/langs/rust"),
77
+ prisma: () => import("@shikijs/langs/prisma"),
78
+ ini: loadIni,
79
+ cfg: loadIni,
80
+ conf: loadIni,
81
+ properties: loadIni,
82
+ toml: () => import("@shikijs/langs/toml")
83
+ };
84
+ /** Theme loaders. Same static-import constraint as {@link LANG_LOADERS}. */
85
+ const THEME_LOADERS = {
86
+ "github-light": () => import("@shikijs/themes/github-light"),
87
+ "github-dark": () => import("@shikijs/themes/github-dark"),
88
+ "github-light-default": () => import("@shikijs/themes/github-light-default"),
89
+ "github-dark-default": () => import("@shikijs/themes/github-dark-default"),
90
+ "vitesse-light": () => import("@shikijs/themes/vitesse-light"),
91
+ "vitesse-dark": () => import("@shikijs/themes/vitesse-dark"),
92
+ "min-light": () => import("@shikijs/themes/min-light"),
93
+ "min-dark": () => import("@shikijs/themes/min-dark"),
94
+ "one-light": () => import("@shikijs/themes/one-light"),
95
+ "one-dark-pro": () => import("@shikijs/themes/one-dark-pro"),
96
+ "catppuccin-latte": () => import("@shikijs/themes/catppuccin-latte"),
97
+ "catppuccin-mocha": () => import("@shikijs/themes/catppuccin-mocha"),
98
+ nord: () => import("@shikijs/themes/nord")
99
+ };
100
+ /**
101
+ * Languages loaded when none are configured: what technical documentation
102
+ * actually contains. Anything outside this list falls back to plain text
103
+ * rather than throwing — see {@link createDocsHighlighter}.
104
+ */
105
+ const DEFAULT_DOCS_LANGS = [
106
+ "typescript",
107
+ "tsx",
108
+ "javascript",
109
+ "jsx",
110
+ "json",
111
+ "shellscript",
112
+ "css",
113
+ "html",
114
+ "markdown",
115
+ "yaml",
116
+ "diff",
117
+ "sql",
118
+ "python",
119
+ "go",
120
+ "rust",
121
+ "prisma",
122
+ "ini",
123
+ "toml"
124
+ ];
125
+ /** Default theme pair. */
126
+ const DEFAULT_DOCS_THEMES = {
127
+ light: "github-light",
128
+ dark: "github-dark"
129
+ };
130
+ /**
131
+ * Highlighters are cached per resolved option set, not per call.
132
+ *
133
+ * The *promise* is cached rather than the resolved highlighter because build
134
+ * tools call this concurrently — Next.js renders route segments in parallel —
135
+ * and caching after the await races into two grammar loads. Keyed rather than
136
+ * a single slot so that a second call
137
+ * with different languages gets a highlighter that actually has them.
138
+ */
139
+ const highlighters = /* @__PURE__ */ new Map();
140
+ function isDocsLang(name) {
141
+ return Object.hasOwn(LANG_LOADERS, name);
142
+ }
143
+ function isDocsTheme(name) {
144
+ return Object.hasOwn(THEME_LOADERS, name);
145
+ }
146
+ /**
147
+ * Create (or reuse) the process-wide highlighter for a given option set.
148
+ *
149
+ * Unknown language and theme names throw rather than degrading: the set is
150
+ * fixed at build time, so a typo in config is a build bug, and silently
151
+ * shipping unhighlighted code is exactly the failure this package exists to
152
+ * avoid. If you need a grammar outside the curated set, build your own
153
+ * `createHighlighterCore` and hand it to `createDocsRenderer({ highlighter })`.
154
+ */
155
+ function createDocsHighlighter(options = {}) {
156
+ const themes = options.themes ?? DEFAULT_DOCS_THEMES;
157
+ 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
+ const langs = [...new Set(requested)].sort();
160
+ const loaders = /* @__PURE__ */ new Set();
161
+ 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
+ loaders.add(LANG_LOADERS[lang]);
164
+ }
165
+ const key = JSON.stringify({
166
+ langs,
167
+ themes
168
+ });
169
+ const cached = highlighters.get(key);
170
+ if (cached) return cached;
171
+ const created = createHighlighterCore({
172
+ engine: createJavaScriptRegexEngine(),
173
+ themes: [THEME_LOADERS[themes.light], THEME_LOADERS[themes.dark]],
174
+ langs: [...loaders]
175
+ }).catch((error) => {
176
+ highlighters.delete(key);
177
+ throw error;
178
+ });
179
+ highlighters.set(key, created);
180
+ return created;
181
+ }
182
+ //#endregion
183
+ export { DEFAULT_DOCS_LANGS, DEFAULT_DOCS_THEMES, createDocsHighlighter };
package/dist/meta.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { DocNavNode, DocsMeta } from "./types.js";
2
+ import { z } from "zod";
3
+ //#region src/meta.d.ts
4
+ /**
5
+ * Zod mirror of {@link DocsMeta}.
6
+ *
7
+ * Strict on purpose: `meta.json` is hand-written and unvalidated keys are
8
+ * almost always typos (`page`, `order`, `items`) that would otherwise fail
9
+ * silently as a sidebar that quietly ignores half the file.
10
+ */
11
+ declare const docsMetaSchema: z.ZodObject<{
12
+ title: z.ZodExactOptional<z.ZodString>;
13
+ pages: z.ZodExactOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
14
+ title: z.ZodString;
15
+ href: z.ZodString;
16
+ }, z.core.$strict>]>>>;
17
+ }, z.core.$strict>;
18
+ /**
19
+ * One child of a directory, as the ordering algorithm sees it.
20
+ *
21
+ * Drafts arrive as `hidden` entries rather than being filtered out by the
22
+ * caller, so that naming a draft in `meta.json` is not a build error that
23
+ * appears and disappears with `includeDrafts`.
24
+ */
25
+ interface MetaDirEntry {
26
+ /** Name as written in `pages`: filename without `.md`, or directory name. */
27
+ name: string;
28
+ /** Title used for alphabetical ordering. */
29
+ title: string;
30
+ /** Frontmatter `order`, when the page (or a directory's index) sets one. */
31
+ order?: number;
32
+ /** The node this entry contributes when it appears in the nav. */
33
+ node: DocNavNode;
34
+ /** Directories only: the children `"...name"` splices in at its position. */
35
+ inlineChildren?: DocNavNode[];
36
+ /**
37
+ * Directories only: a link to the directory's own page, spliced in ahead of
38
+ * {@link MetaDirEntry.inlineChildren}.
39
+ *
40
+ * Inline expansion drops the group node, and the group node is the only
41
+ * carrier of the directory's `href` — without this, `"...api"` publishes
42
+ * `/docs/api` as a route that no sidebar link reaches.
43
+ */
44
+ indexNode?: DocNavNode;
45
+ /** The directory's own `index.md`: listed only when named explicitly. */
46
+ isIndex?: boolean;
47
+ /** Excluded from output, but still resolvable by name (i.e. a draft). */
48
+ hidden?: boolean;
49
+ }
50
+ /**
51
+ * Validate a parsed `meta.json`, or throw an error that names the file.
52
+ */
53
+ declare function parseDocsMeta(raw: unknown, filePath: string): DocsMeta;
54
+ /**
55
+ * Read and validate `<dirPath>/meta.json`. Resolves to `undefined` when the
56
+ * file does not exist — most directories do not need one.
57
+ */
58
+ declare function readDocsMeta(dirPath: string): Promise<DocsMeta | undefined>;
59
+ /**
60
+ * Order a directory's children into nav nodes.
61
+ *
62
+ * With no `pages` list, entries sort by frontmatter `order` ascending and then
63
+ * by title; entries without an order sort after those with one. With a `pages`
64
+ * list, entries appear exactly where they are named, and anything unnamed is
65
+ * dropped unless a `"..."` entry says where to put it.
66
+ *
67
+ * @param entries - The directory's children.
68
+ * @param meta - Its validated `meta.json`, if any.
69
+ * @param metaPath - Path to that `meta.json`, used in error messages.
70
+ * @throws When `pages` names a child that does not exist — always a typo, and
71
+ * catching it here is the entire reason `meta.json` is validated at build.
72
+ */
73
+ declare function orderNavEntries(entries: readonly MetaDirEntry[], meta: DocsMeta | undefined, metaPath: string): DocNavNode[];
74
+ //#endregion
75
+ export { MetaDirEntry, docsMetaSchema, orderNavEntries, parseDocsMeta, readDocsMeta };
package/dist/meta.js ADDED
@@ -0,0 +1,183 @@
1
+ import { z } from "zod";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ //#region src/meta.ts
5
+ /**
6
+ * `meta.json` — per-directory ordering, labelling and hand-written nav entries.
7
+ *
8
+ * The ordering algorithm is kept free of filesystem access on purpose: it
9
+ * takes a description of a directory's children ({@link MetaDirEntry}) and
10
+ * returns nav nodes, so it can be tested exhaustively without a fixture tree
11
+ * and reused by anything that can describe a directory.
12
+ */
13
+ /** A `"---Label---"` separator entry. */
14
+ const SEPARATOR_PATTERN = /^---(.+)---$/;
15
+ /** The rest wildcard: everything not named explicitly, in place. */
16
+ const REST = "...";
17
+ /**
18
+ * `<scheme>:` or protocol-relative `//host` — i.e. a URL that leaves the site.
19
+ * Anything else (`/changelog`, `../pricing`) is internal.
20
+ */
21
+ const ABSOLUTE_HREF_PATTERN = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
22
+ /**
23
+ * Zod mirror of {@link DocsMeta}.
24
+ *
25
+ * Strict on purpose: `meta.json` is hand-written and unvalidated keys are
26
+ * almost always typos (`page`, `order`, `items`) that would otherwise fail
27
+ * silently as a sidebar that quietly ignores half the file.
28
+ */
29
+ const docsMetaSchema = z.strictObject({
30
+ title: z.string().exactOptional(),
31
+ pages: z.array(z.union([z.string(), z.strictObject({
32
+ title: z.string(),
33
+ href: z.string()
34
+ })])).exactOptional()
35
+ });
36
+ /**
37
+ * Validate a parsed `meta.json`, or throw an error that names the file.
38
+ */
39
+ function parseDocsMeta(raw, filePath) {
40
+ const result = docsMetaSchema.safeParse(raw);
41
+ if (result.success) return result.data;
42
+ const details = result.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
43
+ throw new Error(`Invalid meta.json at ${filePath}:\n${details}`);
44
+ }
45
+ /**
46
+ * Read and validate `<dirPath>/meta.json`. Resolves to `undefined` when the
47
+ * file does not exist — most directories do not need one.
48
+ */
49
+ async function readDocsMeta(dirPath) {
50
+ const filePath = path.join(dirPath, "meta.json");
51
+ let raw;
52
+ try {
53
+ raw = await readFile(filePath, "utf8");
54
+ } catch (err) {
55
+ if (isNotFound(err)) return;
56
+ throw err;
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch (err) {
62
+ const reason = err instanceof Error ? err.message : String(err);
63
+ throw new Error(`Could not parse ${filePath} as JSON: ${reason}`);
64
+ }
65
+ return parseDocsMeta(parsed, filePath);
66
+ }
67
+ /**
68
+ * Order a directory's children into nav nodes.
69
+ *
70
+ * With no `pages` list, entries sort by frontmatter `order` ascending and then
71
+ * by title; entries without an order sort after those with one. With a `pages`
72
+ * list, entries appear exactly where they are named, and anything unnamed is
73
+ * dropped unless a `"..."` entry says where to put it.
74
+ *
75
+ * @param entries - The directory's children.
76
+ * @param meta - Its validated `meta.json`, if any.
77
+ * @param metaPath - Path to that `meta.json`, used in error messages.
78
+ * @throws When `pages` names a child that does not exist — always a typo, and
79
+ * catching it here is the entire reason `meta.json` is validated at build.
80
+ */
81
+ function orderNavEntries(entries, meta, metaPath) {
82
+ const pages = meta?.pages;
83
+ if (pages === void 0) return dropEmptyGroups(sortEntries(entries.filter((entry) => isListedByDefault(entry))).map((entry) => entry.node));
84
+ const byName = indexByName(entries, metaPath);
85
+ const used = /* @__PURE__ */ new Set();
86
+ const nodes = [];
87
+ let restAt = -1;
88
+ for (const page of pages) {
89
+ if (typeof page !== "string") {
90
+ nodes.push({
91
+ type: "link",
92
+ title: page.title,
93
+ href: page.href,
94
+ external: ABSOLUTE_HREF_PATTERN.test(page.href)
95
+ });
96
+ continue;
97
+ }
98
+ const separator = SEPARATOR_PATTERN.exec(page);
99
+ if (separator) {
100
+ nodes.push({
101
+ type: "separator",
102
+ title: (separator[1] ?? "").trim()
103
+ });
104
+ continue;
105
+ }
106
+ if (page === REST) {
107
+ if (restAt !== -1) throw new Error(`${metaPath} has more than one "..." entry. A directory has a single set of unnamed pages, so only one wildcard can be honoured — remove the extra.`);
108
+ restAt = nodes.length;
109
+ continue;
110
+ }
111
+ if (page.startsWith(REST)) {
112
+ const name = page.slice(3);
113
+ const target = byName.get(name);
114
+ if (!target?.inlineChildren) throw new Error(`${metaPath} entry "${page}" expands a directory named "${name}", which is not a subdirectory here. ${describeAvailable(entries, true)}`);
115
+ used.add(name);
116
+ if (target.indexNode !== void 0) nodes.push(target.indexNode);
117
+ nodes.push(...target.inlineChildren);
118
+ continue;
119
+ }
120
+ const target = byName.get(page);
121
+ if (!target) throw new Error(`${metaPath} lists "${page}", which does not exist. ${describeAvailable(entries, false)}`);
122
+ used.add(page);
123
+ if (!target.hidden) nodes.push(target.node);
124
+ }
125
+ if (restAt !== -1) {
126
+ const rest = sortEntries(entries.filter((entry) => isListedByDefault(entry) && !used.has(entry.name))).map((entry) => entry.node);
127
+ nodes.splice(restAt, 0, ...rest);
128
+ }
129
+ return dropEmptyGroups(nodes);
130
+ }
131
+ /**
132
+ * Index the directory's children by the name `pages` addresses them with.
133
+ *
134
+ * A plain `new Map(entries.map(…))` is last-wins, which turns an ambiguous
135
+ * name into a page that silently disappears from the sidebar while remaining a
136
+ * published route. The source layer merges the one ambiguity that has a sane
137
+ * reading — `guides.md` beside a `guides/` with no `index.md` — so anything
138
+ * still colliding here is a name no `meta.json` entry could address.
139
+ */
140
+ function indexByName(entries, metaPath) {
141
+ const byName = /* @__PURE__ */ new Map();
142
+ for (const entry of entries) {
143
+ const clash = byName.get(entry.name);
144
+ if (clash !== void 0) throw new Error(`${metaPath} cannot address "${entry.name}": ${describeEntry(clash)} and ${describeEntry(entry)} both claim that name. Rename one.`);
145
+ byName.set(entry.name, entry);
146
+ }
147
+ return byName;
148
+ }
149
+ function describeEntry(entry) {
150
+ return entry.inlineChildren === void 0 ? `${entry.name}.md` : `${entry.name}/`;
151
+ }
152
+ /**
153
+ * Groups that turned out to hold nothing (every page a draft, or none of them
154
+ * named in a child `meta.json`) are dropped here rather than earlier so that
155
+ * naming such a directory in `meta.json` never fails the build.
156
+ */
157
+ function dropEmptyGroups(nodes) {
158
+ return nodes.filter((node) => node.type !== "group" || node.children.length > 0 || node.href !== void 0);
159
+ }
160
+ /** An entry appears without being named: not the index, not a draft. */
161
+ function isListedByDefault(entry) {
162
+ return entry.isIndex !== true && entry.hidden !== true;
163
+ }
164
+ function sortEntries(entries) {
165
+ return [...entries].sort(compareEntries);
166
+ }
167
+ function compareEntries(a, b) {
168
+ if (a.order !== void 0 && b.order !== void 0) {
169
+ if (a.order !== b.order) return a.order - b.order;
170
+ } else if (a.order !== void 0) return -1;
171
+ else if (b.order !== void 0) return 1;
172
+ return a.title.localeCompare(b.title, "en");
173
+ }
174
+ function describeAvailable(entries, directoriesOnly) {
175
+ const names = entries.filter((entry) => !directoriesOnly || entry.inlineChildren !== void 0).map((entry) => entry.name).sort();
176
+ const label = directoriesOnly ? "Subdirectories" : "Available entries";
177
+ return names.length === 0 ? `${label}: (none).` : `${label}: ${names.join(", ")}.`;
178
+ }
179
+ function isNotFound(err) {
180
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
181
+ }
182
+ //#endregion
183
+ export { docsMetaSchema, orderNavEntries, parseDocsMeta, readDocsMeta };