@haruhimemoe/next-kit 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-10-04
10
+
11
+ ### Added
12
+ - `docs` entry point: a content registry (`defineContent`, `CONTENT_SECTIONS`, `SECTION_LABELS`) and the path helpers (`contentPath`, `markdownPath`, `findEntry`, `contentParams`) for an app's docs, guides and legal pages, plus app-made extra entries like bb's tag pages. No runtime imports.
13
+ - `mdxToMarkdown` in `docs`: converts bb-flavored MDX to plain Markdown (callouts to blockquotes, import/export lines dropped, capitalized JSX removed, root-relative links and images absolutized, a title heading added when missing). Content inside fenced code blocks is left untouched. Still no runtime imports.
14
+ - `docs/files` entry point: `readContentMarkdown` reads and converts a registered entry's markdown file, and `contentFileDrift` compares a registry against the files on disk. Loads `node:fs`.
15
+ - `contentLlmsTxt`, `contentLlmsFull`, `contentSitemap` and `contentRewrites` in `docs`: llms.txt (sections in order Docs, Guides, API, Legal, empty ones left out), llms-full.txt (each entry's own leading H1 stripped, since `llmsFull` writes the part title), sitemap records per section (an index path, each entry, each extra) and the one rewrite rule for a content page's ".md" mirror. Built on `llmsTxt`/`llmsFull`/`SitemapRecord` from `seo`. Still no runtime imports.
16
+
17
+ ### Changed
18
+ - **Breaking (check only).** `next-kit check` now also checks files under `content/` and adds the `brand` and `legal` standards: `brand/page.tsx` and `legal/page.tsx` plus `legal/[x]/page.tsx`, `legal/[x]/md/route.ts`, `content/legal/terms.mdx` and `content/legal/privacy.mdx` are required on every app. A `docs` standard (`docs/page.tsx`, `docs/[x]/page.tsx`, `docs/[x]/md/route.ts`) joins in once `src/app/api/v1` exists or any `content/docs` file does; a `guides` standard joins in only once a `content/guides` file does. The `api` standard drops its `docs/api/page.tsx` requirement in favor of `content/docs/api.mdx`. `checkStandards` takes a second `contentFiles` argument; `StandardResult.missing` entries now carry their own `src/app/` or `content/` prefix. This is a 0.x minor: an app with no `/brand`, `/legal` route or legal content fails `next-kit check` in CI until it adds them.
19
+ - `@haruhimemoe/ui` peer range also covers 0.10.0 (not yet published).
20
+
9
21
  ## [0.5.0] - 2026-10-04
10
22
 
11
23
  ### Added
@@ -62,7 +74,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
62
74
  - `@haruhimemoe/next-kit/auth-react`: `createSignedInMarker`, `createAccountStore`, `useAccount`, `createAccount`, `RestoreSignedIn` and `osuSignIn`.
63
75
  - `@haruhimemoe/next-kit/testing`: `startMemoryMongo`, `setupTestDb`, `setupMsw` and the fake osu! app env.
64
76
 
65
- [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.5.0...HEAD
77
+ [unreleased]: https://github.com/haruhimemoe/next-kit/compare/v0.6.0...HEAD
78
+ [0.6.0]: https://github.com/haruhimemoe/next-kit/compare/v0.5.0...v0.6.0
66
79
  [0.5.0]: https://github.com/haruhimemoe/next-kit/compare/v0.4.0...v0.5.0
67
80
  [0.4.0]: https://github.com/haruhimemoe/next-kit/compare/v0.3.0...v0.4.0
68
81
  [0.3.0]: https://github.com/haruhimemoe/next-kit/compare/v0.2.1...v0.3.0
package/README.md CHANGED
@@ -12,6 +12,7 @@ The Next.js server plumbing the haruhime.moe tools share. [packs.haruhime.moe](h
12
12
  - **`/seo`:** Next.js metadata that keeps each page's canonical, og:url and preview image together, robots.txt with the AI crawler stance written down, sitemap entries with honest lastmod, schema.org JSON-LD builders, and llms.txt. No runtime imports; all four haruhime.moe sites use it.
13
13
  - **`/testing`:** Vitest helpers: one in-memory MongoDB per run, an msw server that refuses unhandled requests, and a fake env.
14
14
  - **`/api-keys`:** the shared key format (an app prefix like `hpk_` plus 32 random bytes), a key store over `api_keys`, and the `/api/v1` guard with the standard limits.
15
+ - **`/docs`:** a content registry for an app's docs, guides and legal pages: sections, entries, app-made extra entries (like bb's tag pages), the path helpers a dynamic route needs, and `mdxToMarkdown` to turn bb-flavored MDX into plain Markdown. No runtime imports. **`/docs/files`:** reads the markdown files a registry's entries point at and reports drift between the registry and disk (node:fs).
15
16
 
16
17
  Every name, path, limit and message comes from the caller. There is no root entry point; import a subpath.
17
18
 
@@ -30,10 +31,12 @@ bun add @haruhimemoe/next-kit zod
30
31
  | `env` | nothing else |
31
32
  | `mongo` | `mongodb` ^7.6.0, `mongoose` ^9.10.2 |
32
33
  | `auth` | `better-auth` ^1.7.5, `mongodb`, `@haruhimemoe/osu` 0.2 or 0.3 |
33
- | `auth-react` | `react` ^19.3.0, `next` ^16.3.6, `@haruhimemoe/ui` ^0.5.0 \|\| ^0.6.0 \|\| ^0.7.0 \|\| ^0.8.0 \|\| ^0.9.0 (with its theme set up) |
34
+ | `auth-react` | `react` ^19.3.0, `next` ^16.3.6, `@haruhimemoe/ui` ^0.5.0 \|\| ^0.6.0 \|\| ^0.7.0 \|\| ^0.8.0 \|\| ^0.9.0 \|\| ^0.10.0 (with its theme set up) |
34
35
  | `seo` | `next` ^16.3.6 types only (nothing loads at runtime) |
35
36
  | `testing` | `vitest` ^5.0.1, `msw` ^2.15.0, `mongodb-memory-server` ^11.3.0 |
36
37
  | `api-keys` | `mongodb` ^7.6.0 |
38
+ | `docs` | nothing else |
39
+ | `docs/files` | nothing else (`node:fs` is built in) |
37
40
 
38
41
  ## Use
39
42
 
@@ -149,15 +152,84 @@ export default async () => sitemapEntries(SEO_SITE, [["/", "/search"], pools.map
149
152
  export const GET = () => textResponse(llmsTxt({ title: "pools.haruhime.moe", summary: SEO_SITE.description, sections }));
150
153
  ```
151
154
 
155
+ ### Docs (./docs)
156
+
157
+ One registry per app, built from its docs, guides and legal entries; the crawl helpers and a dynamic route are built on top of it.
158
+
159
+ ```ts
160
+ // src/constants/content.ts
161
+ import { defineContent } from "@haruhimemoe/next-kit/docs";
162
+
163
+ export const CONTENT = defineContent({
164
+ docs: [{ slug: "api", title: "API", description: "The /api/v1 reference.", lastUpdated: "2026-10-04" }],
165
+ guides: [{ slug: "make-a-pack", title: "Make a pack", description: "Build your first mappool.", lastUpdated: "2026-10-04" }],
166
+ legal: [
167
+ { slug: "terms", title: "Terms of service", description: "The rules for using pools.", lastUpdated: "2026-10-04" },
168
+ { slug: "privacy", title: "Privacy policy", description: "What pools stores and why.", lastUpdated: "2026-10-04" },
169
+ ],
170
+ });
171
+
172
+ // src/app/docs/[slug]/page.tsx (and guides/, legal/, the same shape)
173
+ import { findEntry } from "@haruhimemoe/next-kit/docs";
174
+ import { readContentMarkdown } from "@haruhimemoe/next-kit/docs/files";
175
+ import { CONTENT } from "../../../constants/content";
176
+
177
+ export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
178
+ const { slug } = await params;
179
+ const entry = findEntry(CONTENT, "docs", slug);
180
+ if (!entry) return notFound();
181
+ const markdown = await readContentMarkdown(CONTENT, "docs", slug, { siteUrl: SEO_SITE.url });
182
+ return <Markdown>{markdown}</Markdown>;
183
+ }
184
+
185
+ // src/app/llms.txt/route.ts
186
+ import { contentLlmsTxt } from "@haruhimemoe/next-kit/docs";
187
+ import { textResponse } from "@haruhimemoe/next-kit/seo";
188
+
189
+ export const GET = () =>
190
+ textResponse(contentLlmsTxt({ site: SEO_SITE, title: "pools.haruhime.moe", summary: SEO_SITE.description, content: CONTENT }));
191
+
192
+ // src/app/llms-full.txt/route.ts
193
+ import { contentLlmsFull } from "@haruhimemoe/next-kit/docs";
194
+ import { readContentMarkdown } from "@haruhimemoe/next-kit/docs/files";
195
+
196
+ export const GET = async () =>
197
+ textResponse(
198
+ await contentLlmsFull({
199
+ site: SEO_SITE, title: "pools.haruhime.moe", content: CONTENT,
200
+ read: (section, slug) => readContentMarkdown(CONTENT, section, slug, { siteUrl: SEO_SITE.url }),
201
+ }),
202
+ );
203
+
204
+ // src/app/sitemap.ts
205
+ import { contentSitemap } from "@haruhimemoe/next-kit/docs";
206
+
207
+ export default async () => sitemapEntries(SEO_SITE, [["/", "/search"], contentSitemap(CONTENT)]);
208
+
209
+ // next.config.ts
210
+ import { contentRewrites } from "@haruhimemoe/next-kit/docs";
211
+
212
+ export default { async rewrites() { return contentRewrites(); } };
213
+ ```
214
+
152
215
  ## Standards check
153
216
 
154
- `next-kit check [dir]` walks `src/app` (default: the current directory) and confirms every standard route exists, so CI catches a missing one before a page does:
217
+ `next-kit check [dir]` walks `src/app` and, when it exists, `content/` (both under `dir`, default: the current directory) and confirms every standard file exists, so CI catches a missing one before a page does. It checks files only; the content registry itself is never parsed.
155
218
 
156
219
  ```sh
157
220
  bunx next-kit check
158
221
  ```
159
222
 
160
- It always checks the crawl files: `robots.ts`, `sitemap.ts`, `llms.txt/route.ts`, `llms-full.txt/route.ts`, and `.well-known/security.txt/route.ts` (each also accepted as a route handler, like `robots.txt/route.ts`). Once an app has `src/app/api/v1/`, it also checks the public API: `api/v1/me/route.ts`, `api/v1/openapi.json/route.ts`, `api/me/api-key/route.ts`, and a `docs/api` page (a dynamic `docs/[slug]/page.tsx` counts too). Route groups like `(public)/` are ignored, since they don't change the URL.
223
+ Every app is checked against:
224
+
225
+ - **crawl** (always): `robots.ts`, `sitemap.ts`, `llms.txt/route.ts`, `llms-full.txt/route.ts`, and `.well-known/security.txt/route.ts` (each also accepted as a route handler, like `robots.txt/route.ts`).
226
+ - **brand** (always): `brand/page.tsx`.
227
+ - **legal** (always): `legal/page.tsx`, `legal/[x]/page.tsx`, `legal/[x]/md/route.ts` (any dynamic segment name), and `content/legal/terms.mdx` plus `content/legal/privacy.mdx`.
228
+ - **docs**, once `src/app/api/v1` exists or any `content/docs` file does: `docs/page.tsx`, `docs/[x]/page.tsx`, `docs/[x]/md/route.ts`.
229
+ - **guides**, only once a `content/guides` file exists: the same three files under `guides/`. An app with no guides is never asked for them.
230
+ - **api**, once `src/app/api/v1` exists: `api/v1/me/route.ts`, `api/v1/openapi.json/route.ts`, `api/me/api-key/route.ts`, and `content/docs/api.mdx`.
231
+
232
+ Route groups like `(public)/` are ignored, since they don't change the URL. A missing route file prints as `missing src/app/<path>`; a missing content file prints as `missing content/<path>`.
161
233
 
162
234
  The command prints one `pass` or `FAIL` line per standard, names each missing file, and exits 1 on a failure (or when `src/app` is missing). Add it to CI:
163
235
 
@@ -290,6 +362,35 @@ Since 0.3.0. Every helper takes the app's `Site`: `name`, `url` (the canonical o
290
362
  | `API_SERVER_ERROR` | The 500 message when a key lookup or handler throws. |
291
363
  | `createApiKeyGuard({ store, limiter, resolveCaller, messages, limits?, now? })` | Returns `withApiKey(handler)`: a `/api/v1` route handler that runs `handler(request, caller, context)` only for a good key under `API_LIMITS`, with `RateLimit-*` headers, `Cache-Control: no-store`, a 401 with `WWW-Authenticate: Bearer` for a missing or bad key (counted per IP), and a JSON 500 for a thrown error. No CORS headers: the API is for servers and bots. |
292
364
 
365
+ ### docs
366
+
367
+ No runtime imports.
368
+
369
+ | Export | What it does |
370
+ | --- | --- |
371
+ | `CONTENT_SECTIONS`, `ContentSection` | The sections a site can have, in display order: `"docs"`, `"guides"`, `"legal"`. |
372
+ | `SECTION_LABELS` | The nav label for each section, like "Guides". |
373
+ | `defineContent(input)` | Validates and fills in a `Content`: `sections` lists only the non-empty ones, in `CONTENT_SECTIONS` order; `entries` and `extra` hold every section (empty arrays for the ones left out). Throws naming the section and slug (or extra href) for a bad slug (lowercase words, single hyphens), a duplicate slug or extra href, a `lastUpdated` that isn't a real `YYYY-MM-DD` date, or a blank title. |
374
+ | `ContentEntry`, `HowToStep` | A markdown-backed page: `slug`, `title`, `navTitle?`, `description`, `lastUpdated` (`YYYY-MM-DD`), `howTo?` (numbered steps). |
375
+ | `ExtraEntry` | An app-made page shown in a section's nav and search, like bb's tag pages: `href`, `title`, `navTitle?`, `description`, `group`, `badge?`, `lastUpdated?`, `markdownHref?`. |
376
+ | `contentPath(section, slug)`, `markdownPath(section, slug)` | `/section/slug` and `/section/slug.md`. |
377
+ | `findEntry(content, section, slug)` | The matching `ContentEntry`, or undefined. |
378
+ | `contentParams(content, section)` | `{ slug }[]` for a dynamic route's `generateStaticParams`. |
379
+ | `mdxToMarkdown(source, { title, siteUrl, transforms? })` | Converts bb-flavored MDX to plain Markdown, outside fenced code blocks only: CRLF/CR become LF (`transforms` run first, on the whole source); top-level `import`/`export` lines and an `export const x = {` block are dropped; `<Callout type="..." title="...">body</Callout>` becomes a blockquote (`> **Type:** body`, type missing means "Note"); other capitalized JSX tags are removed (text between them stays, lowercase HTML tags stay); a link or image target starting with a single `/` becomes absolute with `siteUrl`; `title` is prepended as a `# ` heading when the first non-blank line isn't one; runs of 3+ blank lines collapse to 2, and the result ends with exactly one newline. |
380
+ | `contentLlmsTxt({ site, title, summary, notes?, content, api? })` | The llms.txt body: sections in order Docs, Guides, API, Legal. Each entry links to its absolute `.md` URL with its description as the note; an extra links to `markdownHref` when set, else `href`. An empty section (no entries, no extras, no `api` links) is left out. |
381
+ | `contentLlmsFull({ site, title, summary?, content, read, before?, after? })` | The llms-full.txt body: `before`, then every registry entry in section order (title, its absolute page URL, and `read(section, slug)`'s Markdown with its own leading `# ` heading stripped, since `llmsFull` writes the part title as the H1), then `after`. `read` is usually `readContentMarkdown` from `docs/files`. |
382
+ | `contentSitemap(content)` | A `SitemapRecord[]` for `seo`'s `sitemapEntries`: one non-empty section's index path (like `/docs`, `lastModified` set to the newest `lastUpdated` among its entries and extras, omitted when none have one), then each entry with its own `lastUpdated`, then each extra with its own `lastUpdated` when set. |
383
+ | `contentRewrites()` | The one Next.js rewrite rule that mirrors a content page's `.md` URL (`/docs/x.md`, `/guides/x.md`, `/legal/x.md`) to its route handler (`/docs/x/md`, ...). Pure, takes no registry. |
384
+
385
+ ### docs/files
386
+
387
+ `node:fs`. Markdown source for an entry lives at `<root>/content/<section>/<slug>.mdx`.
388
+
389
+ | Export | What it does |
390
+ | --- | --- |
391
+ | `readContentMarkdown(content, section, slug, { root?, siteUrl, transforms? })` | Reads a registered entry's markdown file and converts it with `mdxToMarkdown` (using the entry's `title`). `root` defaults to `process.cwd()`. Returns null for an unregistered slug; rejects (ENOENT) when the slug is registered but its file is missing. |
392
+ | `contentFileDrift(content, { root? })` | `{ missingFiles, unregistered }`: `missingFiles` lists registered entries with no file on disk (like `"guides/x.mdx"`); `unregistered` lists `.mdx` files on disk with no registry entry. `root` defaults to `process.cwd()`. |
393
+
293
394
  ## Migration
294
395
 
295
396
  Both apps can drop their copies for the subpaths above. Where the copies differed, this package keeps pools.haruhime.moe's behavior. What changes for packs.haruhime.moe:
@@ -308,7 +409,7 @@ For pools.haruhime.moe, `createMongo` runs `onConnect` (the privilege check, ind
308
409
 
309
410
  ## Compatibility
310
411
 
311
- ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react`, `next/navigation.js` and `@haruhimemoe/ui`. `server` loads `node:crypto` for machine auth. `seo` loads nothing at runtime (Next's types only), so it runs anywhere.
412
+ ES modules for Node 22.12+ on the server. `auth-react` also runs in browsers; its hook and component files keep `"use client"`, and it loads only `react`, `next/navigation.js` and `@haruhimemoe/ui`. `server` loads `node:crypto` for machine auth. `seo` and `docs` load nothing at runtime (Next's types only, or nothing), so they run anywhere; `docs/files` loads `node:fs` and stays server only.
312
413
 
313
414
  ## License
314
415
 
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * @file src/check/cli.ts
4
- * @desc `next-kit check [dir]`: lists src/app under dir (default: the working directory), runs
5
- * checkStandards and prints one line per standard. Exits 1 when one fails or src/app is
6
- * missing.
4
+ * @desc `next-kit check [dir]`: lists src/app and content (when it exists) under dir (default:
5
+ * the working directory), runs checkStandards and prints one line per standard. Exits 1
6
+ * when one fails or src/app is missing.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Sat Oct 3, 2026
9
- * @modified Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  /**
12
12
  * @function runCheck
13
- * @param root {string} the app's root (holds src/app)
13
+ * @param root {string} the app's root (holds src/app and, optionally, content/)
14
14
  * @param log {(line: string) => void} where lines go (default console.log)
15
15
  * @returns {number} 0 when every standard passes, otherwise 1
16
16
  */
package/dist/check/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * @file src/check/cli.ts
4
- * @desc `next-kit check [dir]`: lists src/app under dir (default: the working directory), runs
5
- * checkStandards and prints one line per standard. Exits 1 when one fails or src/app is
6
- * missing.
4
+ * @desc `next-kit check [dir]`: lists src/app and content (when it exists) under dir (default:
5
+ * the working directory), runs checkStandards and prints one line per standard. Exits 1
6
+ * when one fails or src/app is missing.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Sat Oct 3, 2026
9
- * @modified Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  import { existsSync, readdirSync, realpathSync } from "node:fs";
12
12
  import { join, relative, sep } from "node:path";
@@ -14,9 +14,10 @@ import { argv, cwd, exit } from "node:process";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import { checkStandards } from "./standards.js";
16
16
  const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)]);
17
+ const relativeFiles = (dir) => walk(dir).map((file) => relative(dir, file).split(sep).join("/"));
17
18
  /**
18
19
  * @function runCheck
19
- * @param root {string} the app's root (holds src/app)
20
+ * @param root {string} the app's root (holds src/app and, optionally, content/)
20
21
  * @param log {(line: string) => void} where lines go (default console.log)
21
22
  * @returns {number} 0 when every standard passes, otherwise 1
22
23
  */
@@ -26,12 +27,13 @@ export const runCheck = (root, log = console.log) => {
26
27
  log(`next-kit check: no src/app in ${root}`);
27
28
  return 1;
28
29
  }
29
- const files = walk(app).map((file) => relative(app, file).split(sep).join("/"));
30
- const results = checkStandards(files);
30
+ const contentDir = join(root, "content");
31
+ const contentFiles = existsSync(contentDir) ? relativeFiles(contentDir) : [];
32
+ const results = checkStandards(relativeFiles(app), contentFiles);
31
33
  for (const result of results) {
32
34
  log(`${result.ok ? "pass" : "FAIL"} ${result.label}`);
33
- for (const name of result.missing)
34
- log(` missing src/app/${name}`);
35
+ for (const missing of result.missing)
36
+ log(` missing ${missing}`);
35
37
  }
36
38
  return results.every((result) => result.ok) ? 0 : 1;
37
39
  };
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * @file src/check/standards.ts
3
- * @desc The routes every haruhime app serves, checked against an app's file list (paths under
4
- * src/app). Crawl files always; the API standard once api/v1 exists. Brand and guides
5
- * join in phase 2.
3
+ * @desc The routes and content files every haruhime app serves, checked against an app's route
4
+ * file list (paths under src/app) and content file list (paths under content/). Crawl and
5
+ * brand are always checked; legal always checks its three routes plus its two content
6
+ * files; docs joins in once api/v1 exists or a content/docs file does; guides joins in
7
+ * only once a content/guides file does; the API standard, once api/v1 exists, checks its
8
+ * three routes plus content/docs/api.mdx. Checks files only: the content registry itself
9
+ * is never parsed.
6
10
  * @author David @dvhsh (https://dvh.sh)
7
11
  * @created Sat Oct 3, 2026
8
- * @modified Sat Oct 3, 2026
12
+ * @modified Sun Oct 4, 2026
9
13
  */
10
- /** One standard's outcome. */
14
+ /** One standard's outcome. `missing` entries carry their own prefix (src/app/... or content/...). */
11
15
  export type StandardResult = {
12
16
  id: string;
13
17
  label: string;
@@ -16,7 +20,10 @@ export type StandardResult = {
16
20
  };
17
21
  /**
18
22
  * @function checkStandards
19
- * @param files {readonly string[]} every file under src/app, relative, "/"-separated
20
- * @returns {StandardResult[]} crawl files always, the API standard when api/v1 exists
23
+ * @param appFiles {readonly string[]} every file under src/app, relative, "/"-separated
24
+ * @param contentFiles {readonly string[]} every file under content/, relative, "/"-separated
25
+ * @returns {StandardResult[]} crawl and brand always, legal always, docs once api/v1 exists or
26
+ * a content/docs file does, guides once a content/guides file does, and the API standard once
27
+ * api/v1 exists
21
28
  */
22
- export declare const checkStandards: (files: readonly string[]) => StandardResult[];
29
+ export declare const checkStandards: (appFiles: readonly string[], contentFiles?: readonly string[]) => StandardResult[];
@@ -1,51 +1,85 @@
1
1
  /**
2
2
  * @file src/check/standards.ts
3
- * @desc The routes every haruhime app serves, checked against an app's file list (paths under
4
- * src/app). Crawl files always; the API standard once api/v1 exists. Brand and guides
5
- * join in phase 2.
3
+ * @desc The routes and content files every haruhime app serves, checked against an app's route
4
+ * file list (paths under src/app) and content file list (paths under content/). Crawl and
5
+ * brand are always checked; legal always checks its three routes plus its two content
6
+ * files; docs joins in once api/v1 exists or a content/docs file does; guides joins in
7
+ * only once a content/guides file does; the API standard, once api/v1 exists, checks its
8
+ * three routes plus content/docs/api.mdx. Checks files only: the content registry itself
9
+ * is never parsed.
6
10
  * @author David @dvhsh (https://dvh.sh)
7
11
  * @created Sat Oct 3, 2026
8
- * @modified Sat Oct 3, 2026
12
+ * @modified Sun Oct 4, 2026
9
13
  */
10
- const has = (...patterns) => (files) => files.some((file) => patterns.some((pattern) => pattern.test(file)));
14
+ /** A route under src/app, matched against any of `patterns`. */
15
+ const route = (name, ...patterns) => ({
16
+ missing: `src/app/${name}`,
17
+ matches: (app) => app.some((file) => patterns.some((pattern) => pattern.test(file))),
18
+ });
19
+ /** A file under content/, matched by exact path. */
20
+ const content = (path) => ({
21
+ missing: `content/${path}`,
22
+ matches: (_app, files) => files.includes(path),
23
+ });
11
24
  const CRAWL = [
12
- { name: "robots.ts", matches: has(/^robots\.(ts|js)$/, /^robots\.txt\/route\.(ts|js)$/) },
13
- { name: "sitemap.ts", matches: has(/^sitemap\.(ts|js)$/, /^sitemap\.xml\/route\.(ts|js)$/) },
14
- { name: "llms.txt/route.ts", matches: has(/^llms\.txt\/route\.(ts|js)$/) },
15
- { name: "llms-full.txt/route.ts", matches: has(/^llms-full\.txt\/route\.(ts|js)$/) },
16
- {
17
- name: ".well-known/security.txt/route.ts",
18
- matches: has(/^\.well-known\/security\.txt\/route\.(ts|js)$/),
19
- },
25
+ route("robots.ts", /^robots\.(ts|js)$/, /^robots\.txt\/route\.(ts|js)$/),
26
+ route("sitemap.ts", /^sitemap\.(ts|js)$/, /^sitemap\.xml\/route\.(ts|js)$/),
27
+ route("llms.txt/route.ts", /^llms\.txt\/route\.(ts|js)$/),
28
+ route("llms-full.txt/route.ts", /^llms-full\.txt\/route\.(ts|js)$/),
29
+ route(".well-known/security.txt/route.ts", /^\.well-known\/security\.txt\/route\.(ts|js)$/),
20
30
  ];
31
+ const BRAND = [route("brand/page.tsx", /^brand\/page\.(tsx|jsx|ts|js)$/)];
32
+ /** docs/page.tsx, docs/[x]/page.tsx and docs/[x]/md/route.ts under a section, any segment name. */
33
+ const sectionRoutes = (section) => [
34
+ route(`${section}/page.tsx`, new RegExp(`^${section}/page\\.(tsx|jsx|ts|js)$`)),
35
+ route(`${section}/[x]/page.tsx`, new RegExp(`^${section}/\\[[^\\]]+\\]/page\\.(tsx|jsx|ts|js)$`)),
36
+ route(`${section}/[x]/md/route.ts`, new RegExp(`^${section}/\\[[^\\]]+\\]/md/route\\.(ts|js)$`)),
37
+ ];
38
+ const LEGAL = [
39
+ ...sectionRoutes("legal"),
40
+ content("legal/terms.mdx"),
41
+ content("legal/privacy.mdx"),
42
+ ];
43
+ const DOCS = sectionRoutes("docs");
44
+ const GUIDES = sectionRoutes("guides");
21
45
  const API = [
22
- { name: "api/v1/me/route.ts", matches: has(/^api\/v1\/me\/route\.(ts|js)$/) },
23
- {
24
- name: "api/v1/openapi.json/route.ts",
25
- matches: has(/^api\/v1\/openapi\.json\/route\.(ts|js)$/),
26
- },
27
- { name: "api/me/api-key/route.ts", matches: has(/^api\/me\/api-key\/route\.(ts|js)$/) },
28
- {
29
- name: "docs/api/page.tsx",
30
- matches: has(/^(\([^)]+\)\/)?docs\/(api|\[[^\]]+\])\/page\.(tsx|jsx|ts|js)$/),
31
- },
46
+ route("api/v1/me/route.ts", /^api\/v1\/me\/route\.(ts|js)$/),
47
+ route("api/v1/openapi.json/route.ts", /^api\/v1\/openapi\.json\/route\.(ts|js)$/),
48
+ route("api/me/api-key/route.ts", /^api\/me\/api-key\/route\.(ts|js)$/),
49
+ content("docs/api.mdx"),
32
50
  ];
33
- const evaluate = (id, label, reqs, files) => {
34
- const missing = reqs.filter((req) => !req.matches(files)).map((req) => req.name);
51
+ const evaluate = (id, label, reqs, app, files) => {
52
+ const missing = reqs.filter((req) => !req.matches(app, files)).map((req) => req.missing);
35
53
  return { id, label, ok: missing.length === 0, missing };
36
54
  };
37
55
  /** Route groups like (public)/ don't change the URL, so they are dropped before matching. */
38
56
  const ungrouped = (file) => file.replace(/(^|\/)\([^)]+\)(?=\/)/g, "").replace(/^\//, "");
39
57
  /**
40
58
  * @function checkStandards
41
- * @param files {readonly string[]} every file under src/app, relative, "/"-separated
42
- * @returns {StandardResult[]} crawl files always, the API standard when api/v1 exists
59
+ * @param appFiles {readonly string[]} every file under src/app, relative, "/"-separated
60
+ * @param contentFiles {readonly string[]} every file under content/, relative, "/"-separated
61
+ * @returns {StandardResult[]} crawl and brand always, legal always, docs once api/v1 exists or
62
+ * a content/docs file does, guides once a content/guides file does, and the API standard once
63
+ * api/v1 exists
43
64
  */
44
- export const checkStandards = (files) => {
45
- const flat = files.map(ungrouped);
46
- const results = [evaluate("crawl", "Crawl files", CRAWL, flat)];
47
- if (flat.some((file) => file.startsWith("api/v1/"))) {
48
- results.push(evaluate("api", "Public API", API, flat));
65
+ export const checkStandards = (appFiles, contentFiles = []) => {
66
+ const app = appFiles.map(ungrouped);
67
+ const hasApi = app.some((file) => file.startsWith("api/v1/"));
68
+ const hasDocsContent = contentFiles.some((file) => file.startsWith("docs/"));
69
+ const hasGuidesContent = contentFiles.some((file) => file.startsWith("guides/"));
70
+ const results = [
71
+ evaluate("crawl", "Crawl files", CRAWL, app, contentFiles),
72
+ evaluate("brand", "Brand page", BRAND, app, contentFiles),
73
+ evaluate("legal", "Legal pages", LEGAL, app, contentFiles),
74
+ ];
75
+ if (hasApi || hasDocsContent) {
76
+ results.push(evaluate("docs", "Docs pages", DOCS, app, contentFiles));
77
+ }
78
+ if (hasGuidesContent) {
79
+ results.push(evaluate("guides", "Guides pages", GUIDES, app, contentFiles));
80
+ }
81
+ if (hasApi) {
82
+ results.push(evaluate("api", "Public API", API, app, contentFiles));
49
83
  }
50
84
  return results;
51
85
  };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @file src/docs/crawl.ts
3
+ * @desc /llms.txt, /llms-full.txt, sitemap entries and the ".md" mirror rewrite, built straight
4
+ * from a content registry. Pure: no node: imports or file reads here (an app's
5
+ * `read`/`readContentMarkdown` provides the Markdown), so it runs anywhere `docs` does.
6
+ * Reuses `llmsTxt`/`llmsFull` from `../seo/llms.js` for formatting, so escaping and section
7
+ * shape stay the same as every other haruhime.moe crawl file.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sun Oct 4, 2026
10
+ * @modified Sun Oct 4, 2026
11
+ */
12
+ import { type LlmsFullPart } from "../seo/llms.js";
13
+ import { type Site } from "../seo/site.js";
14
+ import type { SitemapRecord } from "../seo/sitemap.js";
15
+ import { type Content, type ContentSection } from "./registry.js";
16
+ /** The "API" section's links: a title, an already-absolute URL and an optional note. */
17
+ export type ContentApiLink = {
18
+ title: string;
19
+ url: string;
20
+ note?: string;
21
+ };
22
+ /** contentLlmsTxt's options. */
23
+ export type ContentLlmsTxtOptions = {
24
+ site: Site;
25
+ title: string;
26
+ summary: string;
27
+ notes?: readonly string[];
28
+ content: Content;
29
+ /** The "API" section's links, like the OpenAPI document or `/api/v1/me`. */
30
+ api?: readonly ContentApiLink[];
31
+ };
32
+ /**
33
+ * @function contentLlmsTxt
34
+ * @param options {ContentLlmsTxtOptions} the site, the file's head, the registry and the API
35
+ * section's links
36
+ * @returns {string} the llms.txt body, sections in order Docs, Guides, API, Legal; an empty
37
+ * section (no entries, no extras, no `api` links) is left out
38
+ */
39
+ export declare const contentLlmsTxt: (options: ContentLlmsTxtOptions) => string;
40
+ /** contentLlmsFull's options. */
41
+ export type ContentLlmsFullOptions = {
42
+ site: Site;
43
+ title: string;
44
+ summary?: string;
45
+ content: Content;
46
+ /** Reads one entry's Markdown, usually `readContentMarkdown` from `docs/files`. */
47
+ read: (section: ContentSection, slug: string) => Promise<string>;
48
+ /** Parts written before the registry's entries, like a brief introduction. */
49
+ before?: readonly LlmsFullPart[];
50
+ /** Parts written after the registry's entries, like extras an app reads on its own. */
51
+ after?: readonly LlmsFullPart[];
52
+ };
53
+ /**
54
+ * @function contentLlmsFull
55
+ * @param options {ContentLlmsFullOptions} the site, the file's head, the registry, a reader and
56
+ * extra parts to place before and after the registry's entries
57
+ * @returns {Promise<string>} the llms-full.txt body: `before`, then every registry entry in
58
+ * section order (title, its absolute page URL, and `read`'s Markdown with its own leading H1
59
+ * stripped, since `llmsFull` writes the part title as the H1), then `after`
60
+ */
61
+ export declare const contentLlmsFull: (options: ContentLlmsFullOptions) => Promise<string>;
62
+ /**
63
+ * @function contentSitemap
64
+ * @param content {Content} a registry from `defineContent`
65
+ * @returns {SitemapRecord[]} one record per non-empty section: the section's index path (like
66
+ * "/docs") with `lastModified` set to the newest `lastUpdated` among its entries and extras
67
+ * (omitted when none have one), then each entry with its own `lastUpdated`, then each extra
68
+ * with its own `lastUpdated` when set
69
+ */
70
+ export declare const contentSitemap: (content: Content) => SitemapRecord[];
71
+ /** One Next.js rewrite rule: `source` and `destination`. */
72
+ export type ContentRewriteRule = {
73
+ source: string;
74
+ destination: string;
75
+ };
76
+ /**
77
+ * @function contentRewrites
78
+ * @returns {ContentRewriteRule[]} the one rule that mirrors a content page's ".md" URL
79
+ * (`/docs/x.md`, `/guides/x.md`, `/legal/x.md`) to its route handler (`/docs/x/md`, ...)
80
+ */
81
+ export declare const contentRewrites: () => ContentRewriteRule[];
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @file src/docs/crawl.ts
3
+ * @desc /llms.txt, /llms-full.txt, sitemap entries and the ".md" mirror rewrite, built straight
4
+ * from a content registry. Pure: no node: imports or file reads here (an app's
5
+ * `read`/`readContentMarkdown` provides the Markdown), so it runs anywhere `docs` does.
6
+ * Reuses `llmsTxt`/`llmsFull` from `../seo/llms.js` for formatting, so escaping and section
7
+ * shape stay the same as every other haruhime.moe crawl file.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sun Oct 4, 2026
10
+ * @modified Sun Oct 4, 2026
11
+ */
12
+ import { llmsFull, llmsTxt, } from "../seo/llms.js";
13
+ import { absoluteUrl } from "../seo/site.js";
14
+ import { contentPath, markdownPath, SECTION_LABELS, } from "./registry.js";
15
+ const sectionLinks = (site, content, section) => [
16
+ ...content.entries[section].map((entry) => ({
17
+ title: entry.title,
18
+ url: absoluteUrl(site, markdownPath(section, entry.slug)),
19
+ note: entry.description,
20
+ })),
21
+ ...content.extra[section].map((extra) => ({
22
+ title: extra.title,
23
+ url: absoluteUrl(site, extra.markdownHref ?? extra.href),
24
+ note: extra.description,
25
+ })),
26
+ ];
27
+ /**
28
+ * @function contentLlmsTxt
29
+ * @param options {ContentLlmsTxtOptions} the site, the file's head, the registry and the API
30
+ * section's links
31
+ * @returns {string} the llms.txt body, sections in order Docs, Guides, API, Legal; an empty
32
+ * section (no entries, no extras, no `api` links) is left out
33
+ */
34
+ export const contentLlmsTxt = (options) => {
35
+ const { site, title, summary, notes, content, api = [] } = options;
36
+ const sections = [
37
+ { heading: SECTION_LABELS.docs, links: sectionLinks(site, content, "docs") },
38
+ { heading: SECTION_LABELS.guides, links: sectionLinks(site, content, "guides") },
39
+ {
40
+ heading: "API",
41
+ links: api.map(({ title: t, url, note }) => ({ title: t, url, ...(note ? { note } : {}) })),
42
+ },
43
+ { heading: SECTION_LABELS.legal, links: sectionLinks(site, content, "legal") },
44
+ ];
45
+ return llmsTxt({ title, summary, ...(notes ? { notes } : {}), sections });
46
+ };
47
+ /** A leading "# ...\n" line, and the one blank line after it, if any. */
48
+ const LEADING_HEADING = /^# [^\n]*\n\n?/;
49
+ const stripLeadingHeading = (markdown) => markdown.replace(LEADING_HEADING, "");
50
+ /**
51
+ * @function contentLlmsFull
52
+ * @param options {ContentLlmsFullOptions} the site, the file's head, the registry, a reader and
53
+ * extra parts to place before and after the registry's entries
54
+ * @returns {Promise<string>} the llms-full.txt body: `before`, then every registry entry in
55
+ * section order (title, its absolute page URL, and `read`'s Markdown with its own leading H1
56
+ * stripped, since `llmsFull` writes the part title as the H1), then `after`
57
+ */
58
+ export const contentLlmsFull = async (options) => {
59
+ const { site, title, summary, content, read, before = [], after = [] } = options;
60
+ const parts = [...before];
61
+ for (const section of content.sections) {
62
+ for (const entry of content.entries[section]) {
63
+ const markdown = await read(section, entry.slug);
64
+ parts.push({
65
+ title: entry.title,
66
+ url: absoluteUrl(site, contentPath(section, entry.slug)),
67
+ markdown: stripLeadingHeading(markdown),
68
+ });
69
+ }
70
+ }
71
+ parts.push(...after);
72
+ return llmsFull(parts, { title, ...(summary ? { summary } : {}) });
73
+ };
74
+ const newestDate = (dates) => dates.length ? dates.reduce((newest, date) => (date > newest ? date : newest)) : undefined;
75
+ /**
76
+ * @function contentSitemap
77
+ * @param content {Content} a registry from `defineContent`
78
+ * @returns {SitemapRecord[]} one record per non-empty section: the section's index path (like
79
+ * "/docs") with `lastModified` set to the newest `lastUpdated` among its entries and extras
80
+ * (omitted when none have one), then each entry with its own `lastUpdated`, then each extra
81
+ * with its own `lastUpdated` when set
82
+ */
83
+ export const contentSitemap = (content) => {
84
+ const records = [];
85
+ for (const section of content.sections) {
86
+ const entries = content.entries[section];
87
+ const extras = content.extra[section];
88
+ const newest = newestDate([
89
+ ...entries.map((entry) => entry.lastUpdated),
90
+ ...extras.flatMap((extra) => (extra.lastUpdated ? [extra.lastUpdated] : [])),
91
+ ]);
92
+ records.push({ path: `/${section}`, ...(newest ? { lastModified: newest } : {}) });
93
+ for (const entry of entries) {
94
+ records.push({ path: contentPath(section, entry.slug), lastModified: entry.lastUpdated });
95
+ }
96
+ for (const extra of extras) {
97
+ records.push({
98
+ path: extra.href,
99
+ ...(extra.lastUpdated ? { lastModified: extra.lastUpdated } : {}),
100
+ });
101
+ }
102
+ }
103
+ return records;
104
+ };
105
+ /**
106
+ * @function contentRewrites
107
+ * @returns {ContentRewriteRule[]} the one rule that mirrors a content page's ".md" URL
108
+ * (`/docs/x.md`, `/guides/x.md`, `/legal/x.md`) to its route handler (`/docs/x/md`, ...)
109
+ */
110
+ export const contentRewrites = () => [
111
+ {
112
+ source: "/:section(docs|guides|legal)/:slug([a-z0-9-]+).md",
113
+ destination: "/:section/:slug/md",
114
+ },
115
+ ];