@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.
- package/LICENSE +21 -0
- package/README.md +518 -0
- package/dist/frontmatter.d.ts +55 -0
- package/dist/frontmatter.js +80 -0
- package/dist/highlighter.d.ts +99 -0
- package/dist/highlighter.js +183 -0
- package/dist/meta.d.ts +75 -0
- package/dist/meta.js +183 -0
- package/dist/next.d.ts +256 -0
- package/dist/next.js +365 -0
- package/dist/plugins/rehype-capture-toc.d.ts +18 -0
- package/dist/plugins/rehype-capture-toc.js +69 -0
- package/dist/plugins/remark-doc-links.d.ts +63 -0
- package/dist/plugins/remark-doc-links.js +122 -0
- package/dist/plugins/remark-unwrap-images.d.ts +11 -0
- package/dist/plugins/remark-unwrap-images.js +25 -0
- package/dist/plugins/remark-youtube.d.ts +22 -0
- package/dist/plugins/remark-youtube.js +84 -0
- package/dist/react/callout.d.ts +37 -0
- package/dist/react/callout.js +113 -0
- package/dist/react/doc-content.d.ts +29 -0
- package/dist/react/doc-content.js +30 -0
- package/dist/react/markdown-components.d.ts +84 -0
- package/dist/react/markdown-components.js +122 -0
- package/dist/react/search-dialog.d.ts +41 -0
- package/dist/react/search-dialog.js +404 -0
- package/dist/react/sidebar.d.ts +29 -0
- package/dist/react/sidebar.js +196 -0
- package/dist/react/skip-link.d.ts +37 -0
- package/dist/react/skip-link.js +37 -0
- package/dist/react/toc.d.ts +35 -0
- package/dist/react/toc.js +87 -0
- package/dist/react/youtube.d.ts +27 -0
- package/dist/react/youtube.js +75 -0
- package/dist/render.d.ts +72 -0
- package/dist/render.js +279 -0
- package/dist/search-index.d.ts +51 -0
- package/dist/search-index.js +274 -0
- package/dist/search-options.d.ts +18 -0
- package/dist/search-options.js +40 -0
- package/dist/source.d.ts +67 -0
- package/dist/source.js +332 -0
- package/dist/styles.css +1033 -0
- package/dist/types.d.ts +334 -0
- package/dist/types.js +0 -0
- 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 };
|