@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,67 @@
1
+ import { DocFile, DocFrontmatter, DocNavNode, DocsConfig, ResolvedDocsConfig } from "./types.js";
2
+ //#region src/source.d.ts
3
+ /**
4
+ * A content directory, scanned once and queried many times.
5
+ *
6
+ * Every method is async because the first one to be called performs the scan;
7
+ * subsequent calls resolve from the same in-flight promise.
8
+ */
9
+ interface DocsSource<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
10
+ /** Every page, drafts excluded unless `includeDrafts`. */
11
+ all(): Promise<Array<DocFile<TFrontmatter>>>;
12
+ /**
13
+ * One page by route segments. Returns drafts regardless of config, so a
14
+ * preview route can opt into them without a second source.
15
+ */
16
+ find(segments: string[]): Promise<DocFile<TFrontmatter> | undefined>;
17
+ /** The navigation tree for the content root. */
18
+ nav(): Promise<DocNavNode[]>;
19
+ /** Route segments for `generateStaticParams`, drafts excluded. */
20
+ slugs(): Promise<string[][]>;
21
+ /**
22
+ * Discard the cached scan, so the next query reads the disk again.
23
+ *
24
+ * A one-shot `next build` never needs this — the tree cannot change while it
25
+ * runs, and caching is the whole point. A long-lived Vite dev server does:
26
+ * without it, a markdown file created after startup could never appear, and
27
+ * the source would be permanently wrong for the rest of the session.
28
+ */
29
+ invalidate(): void;
30
+ config: ResolvedDocsConfig<TFrontmatter>;
31
+ }
32
+ /**
33
+ * Apply {@link DocsConfig} defaults and resolve `contentDir` against
34
+ * `process.cwd()` — the project root under both `next build` and `vite build`.
35
+ *
36
+ * `basePath` is normalised to have a leading slash and no trailing one, so
37
+ * href construction is a plain concatenation everywhere else. Mounting docs at
38
+ * the site root (`'/'`) normalises to `''`, matching the Next.js convention.
39
+ */
40
+ declare function resolveDocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter>(config: DocsConfig<TFrontmatter>): ResolvedDocsConfig<TFrontmatter>;
41
+ /**
42
+ * Create (or reuse) the source for a content directory.
43
+ */
44
+ declare function createDocsSource<TFrontmatter extends DocFrontmatter = DocFrontmatter>(config: DocsConfig<TFrontmatter>): DocsSource<TFrontmatter>;
45
+ /**
46
+ * A former URL from `aliases` frontmatter, as a route.
47
+ *
48
+ * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
49
+ * Leading and trailing slashes are tolerated because authors write them, but
50
+ * the value is always relative to the base path — an alias of `'/docs/old'` on
51
+ * a `/docs` site would produce `/docs/docs/old`.
52
+ *
53
+ * Shared by both adapters so they agree on which routes exist: an alias is a
54
+ * redirect the host installs, so a link to one resolves, and a link that
55
+ * builds under Next must build under Vite.
56
+ */
57
+ declare function toAliasRoute(alias: string, basePath: string,
58
+ /**
59
+ * The source path, for the error. A STRING rather than the whole `DocFile`
60
+ * it used to take: this function dereferenced exactly one property of it, and
61
+ * demanding the object meant a cache reader or a manifest-driven redirect
62
+ * table had to fabricate a `DocFile` to agree with the package about which
63
+ * routes exist. That is the reason it is exported at all.
64
+ */
65
+ sourceLabel: string): string;
66
+ //#endregion
67
+ export { DocsSource, createDocsSource, resolveDocsConfig, toAliasRoute };
package/dist/source.js ADDED
@@ -0,0 +1,332 @@
1
+ import { parseFrontmatter } from "./frontmatter.js";
2
+ import { orderNavEntries, readDocsMeta } from "./meta.js";
3
+ import { readFile, readdir, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import matter from "gray-matter";
6
+ //#region src/source.ts
7
+ /**
8
+ * The source layer: a content directory on disk becomes `DocFile[]` plus a
9
+ * navigation tree.
10
+ *
11
+ * Node-only, and the only module that touches the filesystem. Everything it
12
+ * produces is plain data, so the result crosses the RSC boundary, a Vite
13
+ * virtual module or a JSON cache file without ceremony.
14
+ */
15
+ /** Markdown only. MDX is deliberately out of scope for this package. */
16
+ const PAGE_EXTENSION = ".md";
17
+ /** A directory whose `index.md` is the directory's own route. */
18
+ const INDEX_NAME = "index";
19
+ /**
20
+ * Apply {@link DocsConfig} defaults and resolve `contentDir` against
21
+ * `process.cwd()` — the project root under both `next build` and `vite build`.
22
+ *
23
+ * `basePath` is normalised to have a leading slash and no trailing one, so
24
+ * href construction is a plain concatenation everywhere else. Mounting docs at
25
+ * the site root (`'/'`) normalises to `''`, matching the Next.js convention.
26
+ */
27
+ function resolveDocsConfig(config) {
28
+ return {
29
+ contentDir: path.resolve(process.cwd(), config.contentDir),
30
+ basePath: normalizeBasePath(config.basePath ?? "/docs"),
31
+ includeDrafts: config.includeDrafts ?? false,
32
+ assertLinks: config.assertLinks ?? true,
33
+ ...config.frontmatterSchema === void 0 ? {} : { frontmatterSchema: config.frontmatterSchema }
34
+ };
35
+ }
36
+ /**
37
+ * Sources are memoised by resolved config so that the twenty route files of a
38
+ * docs site share one filesystem scan instead of each starting their own.
39
+ *
40
+ * The values are heterogeneous in `TFrontmatter`, which no `Map` type can
41
+ * express; the read in {@link createDocsSource} is a cast justified by
42
+ * {@link schemaKey} — a key only matches an entry built from the very same
43
+ * schema object, and therefore from the very same `TFrontmatter`.
44
+ */
45
+ const sources = /* @__PURE__ */ new Map();
46
+ /** Schema identities, assigned on first sight. */
47
+ const schemaIds = /* @__PURE__ */ new WeakMap();
48
+ let schemaIdCount = 0;
49
+ /**
50
+ * A key fragment identifying a `frontmatterSchema`, by reference.
51
+ *
52
+ * Two configs that differ only by schema must not share a source: the cached
53
+ * `DocFile.frontmatter` was parsed by whichever schema arrived first, so
54
+ * sharing would hand one caller the other's fields — silently, and only for the
55
+ * fields the two schemas disagree about. Identity is the only comparison a
56
+ * Standard Schema supports; a validator has no stable serialisation.
57
+ */
58
+ function schemaKey(schema) {
59
+ if (schema === void 0) return "default-schema";
60
+ const existing = schemaIds.get(schema);
61
+ if (existing !== void 0) return `schema-${existing}`;
62
+ schemaIdCount += 1;
63
+ schemaIds.set(schema, schemaIdCount);
64
+ return `schema-${schemaIdCount}`;
65
+ }
66
+ /**
67
+ * Create (or reuse) the source for a content directory.
68
+ */
69
+ function createDocsSource(config) {
70
+ const resolved = resolveDocsConfig(config);
71
+ const key = [
72
+ resolved.contentDir,
73
+ resolved.basePath,
74
+ resolved.includeDrafts,
75
+ resolved.assertLinks,
76
+ schemaKey(resolved.frontmatterSchema)
77
+ ].join("\0");
78
+ const existing = sources.get(key);
79
+ if (existing) return existing;
80
+ const created = buildSource(resolved);
81
+ sources.set(key, created);
82
+ return created;
83
+ }
84
+ function buildSource(config) {
85
+ let cached = null;
86
+ const load = () => cached ??= scan(config);
87
+ const isVisible = (file) => config.includeDrafts || file.frontmatter.draft !== true;
88
+ return {
89
+ config,
90
+ invalidate() {
91
+ cached = null;
92
+ },
93
+ async all() {
94
+ const { files } = await load();
95
+ return files.filter(isVisible);
96
+ },
97
+ async find(segments) {
98
+ const { bySlug } = await load();
99
+ return bySlug.get(segments.join("/"));
100
+ },
101
+ async nav() {
102
+ return (await load()).nav;
103
+ },
104
+ async slugs() {
105
+ const { files } = await load();
106
+ return files.filter(isVisible).map((file) => file.segments);
107
+ }
108
+ };
109
+ }
110
+ async function scan(config) {
111
+ await assertContentDir(config.contentDir);
112
+ const root = await scanDir(config.contentDir, [], "", config);
113
+ const files = [];
114
+ const bySlug = /* @__PURE__ */ new Map();
115
+ collect(root, files, bySlug);
116
+ return {
117
+ files,
118
+ bySlug,
119
+ nav: buildNav(root, config)
120
+ };
121
+ }
122
+ async function assertContentDir(contentDir) {
123
+ try {
124
+ if ((await stat(contentDir)).isDirectory()) return;
125
+ } catch {}
126
+ throw new Error(`Docs content directory not found: ${contentDir}\nSet \`contentDir\` to a directory of markdown files; relative paths resolve against the working directory (${process.cwd()}).`);
127
+ }
128
+ async function scanDir(absPath, segments, name, config) {
129
+ const [meta, entries] = await Promise.all([readDocsMeta(absPath), readdir(absPath, { withFileTypes: true })]);
130
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name, "en"));
131
+ const pageEntries = sorted.filter((entry) => entry.isFile() && isPageFile(entry.name));
132
+ const dirEntries = sorted.filter((entry) => entry.isDirectory() && !isIgnoredDir(entry.name));
133
+ const [pages, dirs] = await Promise.all([Promise.all(pageEntries.map((entry) => readPage(path.join(absPath, entry.name), segments, config))), Promise.all(dirEntries.map((entry) => scanDir(path.join(absPath, entry.name), [...segments, entry.name], entry.name, config)))]);
134
+ const index = pages.find((page) => baseName(page.filePath) === INDEX_NAME);
135
+ return {
136
+ name,
137
+ absPath,
138
+ segments,
139
+ meta,
140
+ metaPath: path.join(absPath, "meta.json"),
141
+ index,
142
+ pages: pages.filter((page) => page !== index),
143
+ dirs
144
+ };
145
+ }
146
+ async function readPage(filePath, dirSegments, config) {
147
+ const raw = await readFile(filePath, "utf8");
148
+ const relativePath = toPosix(path.relative(config.contentDir, filePath));
149
+ let data;
150
+ let content;
151
+ try {
152
+ const parsed = matter(raw, { language: "yaml" });
153
+ data = parsed.data;
154
+ content = parsed.content;
155
+ } catch (err) {
156
+ const reason = err instanceof Error ? err.message : String(err);
157
+ throw new Error(`Could not parse the frontmatter block in ${relativePath}: ${reason}`);
158
+ }
159
+ const name = baseName(filePath);
160
+ const segments = name === INDEX_NAME ? [...dirSegments] : [...dirSegments, name];
161
+ return {
162
+ segments,
163
+ slug: segments.join("/"),
164
+ href: toHref(config.basePath, segments),
165
+ filePath,
166
+ relativePath,
167
+ frontmatter: await parseFrontmatter(data, relativePath, config.frontmatterSchema),
168
+ content
169
+ };
170
+ }
171
+ function collect(dir, files, bySlug) {
172
+ const own = dir.index ? [dir.index, ...dir.pages] : dir.pages;
173
+ for (const file of own) {
174
+ const clash = bySlug.get(file.slug);
175
+ if (clash) throw new Error(`Two files claim the route "${file.href}": ${clash.relativePath} and ${file.relativePath}. Rename one, or delete the other — a directory index and a same-named sibling file collide.`);
176
+ bySlug.set(file.slug, file);
177
+ files.push(file);
178
+ }
179
+ for (const child of dir.dirs) collect(child, files, bySlug);
180
+ }
181
+ function buildNav(dir, config) {
182
+ const entries = [];
183
+ /**
184
+ * `guides.md` beside a `guides/` that has no `index.md` is that directory's
185
+ * own page: both answer to the name `meta.json` addresses them by, and both
186
+ * contribute a nav entry. Merged into one group whose heading links to the
187
+ * page, rather than left to collide — an ambiguous name is how a published
188
+ * route ends up reachable from no link in the sidebar.
189
+ *
190
+ * When the directory *does* have an `index.md`, the two claim the same route
191
+ * and `collect` has already failed the build.
192
+ */
193
+ const mergeable = new Set(dir.dirs.filter((child) => child.index === void 0).map((child) => child.name));
194
+ const dirPages = /* @__PURE__ */ new Map();
195
+ for (const page of dir.pages) {
196
+ const name = baseName(page.filePath);
197
+ if (mergeable.has(name)) {
198
+ dirPages.set(name, page);
199
+ continue;
200
+ }
201
+ entries.push(toPageEntry(page, config));
202
+ }
203
+ if (dir.index) entries.push({
204
+ ...toPageEntry(dir.index, config),
205
+ isIndex: true
206
+ });
207
+ for (const child of dir.dirs) {
208
+ const children = buildNav(child, config);
209
+ const index = child.index ?? dirPages.get(child.name);
210
+ const title = groupTitle(child, index);
211
+ const href = index && isVisibleIn(index, config) ? index.href : void 0;
212
+ const group = {
213
+ type: "group",
214
+ title,
215
+ children,
216
+ ...href !== void 0 ? { href } : {}
217
+ };
218
+ const node = children.length === 0 && index !== void 0 && href !== void 0 ? {
219
+ type: "page",
220
+ title,
221
+ href,
222
+ slug: index.slug
223
+ } : group;
224
+ const order = index?.frontmatter.order;
225
+ entries.push({
226
+ name: child.name,
227
+ title,
228
+ node,
229
+ inlineChildren: children,
230
+ ...index !== void 0 && href !== void 0 ? { indexNode: {
231
+ type: "page",
232
+ title: navTitle(index),
233
+ href,
234
+ slug: index.slug
235
+ } } : {},
236
+ ...order !== void 0 ? { order } : {}
237
+ });
238
+ }
239
+ return orderNavEntries(entries, dir.meta, dir.metaPath);
240
+ }
241
+ function toPageEntry(page, config) {
242
+ const title = navTitle(page);
243
+ const { order } = page.frontmatter;
244
+ return {
245
+ name: baseName(page.filePath),
246
+ title,
247
+ node: {
248
+ type: "page",
249
+ title,
250
+ href: page.href,
251
+ slug: page.slug
252
+ },
253
+ ...order !== void 0 ? { order } : {},
254
+ ...isVisibleIn(page, config) ? {} : { hidden: true }
255
+ };
256
+ }
257
+ /** Sidebars are narrow: `label` wins over `title` when the author set one. */
258
+ function navTitle(page) {
259
+ return page.frontmatter.label ?? page.frontmatter.title;
260
+ }
261
+ /**
262
+ * A group heading: its own `meta.json` title, else the title of the page that
263
+ * *is* the directory (`index.md`, or a merged same-named sibling), else the
264
+ * directory name humanised.
265
+ */
266
+ function groupTitle(dir, index) {
267
+ const fromMeta = dir.meta?.title;
268
+ if (fromMeta !== void 0) return fromMeta;
269
+ if (index) return navTitle(index);
270
+ return humanize(dir.name);
271
+ }
272
+ function isVisibleIn(file, config) {
273
+ return config.includeDrafts || file.frontmatter.draft !== true;
274
+ }
275
+ /**
276
+ * A former URL from `aliases` frontmatter, as a route.
277
+ *
278
+ * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
279
+ * Leading and trailing slashes are tolerated because authors write them, but
280
+ * the value is always relative to the base path — an alias of `'/docs/old'` on
281
+ * a `/docs` site would produce `/docs/docs/old`.
282
+ *
283
+ * Shared by both adapters so they agree on which routes exist: an alias is a
284
+ * redirect the host installs, so a link to one resolves, and a link that
285
+ * builds under Next must build under Vite.
286
+ */
287
+ function toAliasRoute(alias, basePath, sourceLabel) {
288
+ const trimmed = alias.trim().replace(/^\/+/, "").replace(/\/+$/, "");
289
+ if (trimmed === "") throw new Error(`@waveso/docs: ${sourceLabel} has an empty entry in its \`aliases\` frontmatter. Each alias is a former URL for this page, relative to the docs base path — e.g. \`aliases: [quickstart]\`.`);
290
+ return `${basePath}/${trimmed}`;
291
+ }
292
+ /**
293
+ * ⚠️ `_` AND `.` BOTH, MATCHING `isIgnoredDir` BELOW — which is what this did
294
+ * NOT do. A leading dot was skipped and a leading underscore was not, so
295
+ * `_drafts/` was excluded while `_notes.md` beside it was published: routed,
296
+ * listed in the sidebar, written into the sitemap, indexed for search.
297
+ *
298
+ * The asymmetry cannot have been intentional. There is no `ignore` option in
299
+ * `DocsConfig`, and `draft: true` still demands a valid `title`, so the
300
+ * underscore is the ONLY way to keep a markdown file in the tree without
301
+ * publishing it — and it silently did not work for the half people reach for
302
+ * first. Docusaurus and Nextra skip both forms.
303
+ */
304
+ function isPageFile(name) {
305
+ return !name.startsWith(".") && !name.startsWith("_") && name.endsWith(PAGE_EXTENSION) && name.length > 3;
306
+ }
307
+ /** `_drafts/` and `.git/` are not content. */
308
+ function isIgnoredDir(name) {
309
+ return name.startsWith(".") || name.startsWith("_");
310
+ }
311
+ function baseName(filePath) {
312
+ return path.basename(filePath, PAGE_EXTENSION);
313
+ }
314
+ function toHref(basePath, segments) {
315
+ if (segments.length === 0) return basePath === "" ? "/" : basePath;
316
+ return `${basePath}/${segments.join("/")}`;
317
+ }
318
+ function normalizeBasePath(basePath) {
319
+ const trimmed = basePath.trim().replace(/\/+$/, "");
320
+ if (trimmed === "") return "";
321
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
322
+ }
323
+ /** Windows separators never reach a URL or a `relativePath`. */
324
+ function toPosix(value) {
325
+ return value.split(path.sep).join("/");
326
+ }
327
+ /** `getting-started` -> `Getting Started`. */
328
+ function humanize(name) {
329
+ return name.split(/[-_\s]+/).filter((word) => word !== "").map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(" ");
330
+ }
331
+ //#endregion
332
+ export { createDocsSource, resolveDocsConfig, toAliasRoute };