@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 +14 -1
- package/README.md +105 -4
- package/dist/check/cli.d.ts +5 -5
- package/dist/check/cli.js +11 -9
- package/dist/check/standards.d.ts +15 -8
- package/dist/check/standards.js +66 -32
- package/dist/docs/crawl.d.ts +81 -0
- package/dist/docs/crawl.js +115 -0
- package/dist/docs/files/index.d.ts +43 -0
- package/dist/docs/files/index.js +74 -0
- package/dist/docs/index.d.ts +15 -0
- package/dist/docs/index.js +15 -0
- package/dist/docs/markdown-segments.d.ts +37 -0
- package/dist/docs/markdown-segments.js +147 -0
- package/dist/docs/markdown.d.ts +31 -0
- package/dist/docs/markdown.js +117 -0
- package/dist/docs/registry.d.ts +94 -0
- package/dist/docs/registry.js +102 -0
- package/package.json +10 -2
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.
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
|
package/dist/check/cli.d.ts
CHANGED
|
@@ -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:
|
|
5
|
-
* checkStandards and prints one line per standard. Exits 1
|
|
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
|
|
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:
|
|
5
|
-
* checkStandards and prints one line per standard. Exits 1
|
|
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
|
|
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
|
|
30
|
-
const
|
|
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
|
|
34
|
-
log(` missing
|
|
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
|
|
4
|
-
* src/app)
|
|
5
|
-
*
|
|
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
|
|
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
|
|
20
|
-
* @
|
|
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: (
|
|
29
|
+
export declare const checkStandards: (appFiles: readonly string[], contentFiles?: readonly string[]) => StandardResult[];
|
package/dist/check/standards.js
CHANGED
|
@@ -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
|
|
4
|
-
* src/app)
|
|
5
|
-
*
|
|
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
|
|
12
|
+
* @modified Sun Oct 4, 2026
|
|
9
13
|
*/
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
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
|
|
42
|
-
* @
|
|
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 = (
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
];
|