@haruhimemoe/next-kit 0.4.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file src/api-keys/store.ts
3
+ * @desc One API key per user in MongoDB (collection api_keys): issue (replaces), info, revoke,
4
+ * authenticate and account deletion. Only the SHA-256 hash and a display prefix are
5
+ * stored. Two issues racing still leave one key (unique userId; the loser updates).
6
+ * authenticate gives the owner's user id; who that user is stays the app's call.
7
+ * lastUsedAt is written at most once an hour. Moved from packs (src/services/api-keys.ts).
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { ObjectId } from "mongodb";
13
+ import { isDuplicateKeyError } from "../mongo/duplicate.js";
14
+ import { ensureIndexes as buildIndexes } from "../mongo/indexes.js";
15
+ import { apiKeyDisplay, assertApiKeyPrefix, generateApiKey, hashApiKey, isApiKeyFormat, } from "./format.js";
16
+ /** The collection every app keeps its keys in. */
17
+ export const API_KEYS_COLLECTION = "api_keys";
18
+ /** lastUsedAt is written at most this often, to save writes. */
19
+ export const LAST_USED_INTERVAL_MS = 60 * 60 * 1000;
20
+ const toInfo = (doc) => ({
21
+ prefix: doc.prefix,
22
+ createdAt: doc.createdAt.toISOString(),
23
+ lastUsedAt: doc.lastUsedAt ? doc.lastUsedAt.toISOString() : null,
24
+ });
25
+ /**
26
+ * @function apiKeyIndexSpecs
27
+ * @param collection {string} the keys collection (default api_keys)
28
+ * @returns {IndexSpec[]} unique userId and unique hash, for the app's own index list
29
+ */
30
+ export const apiKeyIndexSpecs = (collection = API_KEYS_COLLECTION) => [
31
+ { collection, key: { userId: 1 }, unique: true },
32
+ // The hash is a credential digest: never log its values.
33
+ { collection, key: { hash: 1 }, unique: true, secret: true },
34
+ ];
35
+ /**
36
+ * @function createApiKeyStore
37
+ * @param options {ApiKeyStoreOptions} the app prefix, the database, the collection (default
38
+ * api_keys) and a clock (default Date.now)
39
+ * @returns {ApiKeyStore} the key operations for that app
40
+ * @throws {TypeError} on a bad prefix
41
+ */
42
+ export const createApiKeyStore = ({ prefix, db, collection = API_KEYS_COLLECTION,
43
+ // Read per call, so fake timers in app tests move it.
44
+ now = () => Date.now(), }) => {
45
+ assertApiKeyPrefix(prefix);
46
+ const keys = async () => (await db()).collection(collection);
47
+ const issue = async (userId) => {
48
+ const key = generateApiKey(prefix);
49
+ const filter = { userId: new ObjectId(userId) };
50
+ const update = {
51
+ $set: { prefix: apiKeyDisplay(key), hash: hashApiKey(key), createdAt: new Date(now()) },
52
+ $unset: { lastUsedAt: 1 },
53
+ };
54
+ const upsert = async () => (await keys()).findOneAndUpdate(filter, update, { upsert: true, returnDocument: "after" });
55
+ const doc = await upsert().catch((error) => {
56
+ if (!isDuplicateKeyError(error))
57
+ throw error;
58
+ return upsert();
59
+ });
60
+ if (!doc)
61
+ throw new Error("api key upsert returned no document");
62
+ return { key, apiKey: toInfo(doc) };
63
+ };
64
+ const authenticate = async (key) => {
65
+ if (!isApiKeyFormat(prefix, key))
66
+ return null;
67
+ const hash = hashApiKey(key);
68
+ const collectionRef = await keys();
69
+ const doc = await collectionRef.findOne({ hash });
70
+ if (!doc)
71
+ return null;
72
+ const stamp = async () => {
73
+ const at = now();
74
+ if (doc.lastUsedAt && at - doc.lastUsedAt.getTime() < LAST_USED_INTERVAL_MS)
75
+ return;
76
+ // Filter on the hash too, so a regenerate in between isn't stamped with this use.
77
+ await collectionRef.updateOne({ _id: doc._id, hash }, { $set: { lastUsedAt: new Date(at) } });
78
+ };
79
+ return { userId: doc.userId.toString(), stamp };
80
+ };
81
+ return {
82
+ prefix,
83
+ issue,
84
+ authenticate,
85
+ info: async (userId) => {
86
+ const doc = await (await keys()).findOne({ userId: new ObjectId(userId) });
87
+ return doc ? toInfo(doc) : null;
88
+ },
89
+ revoke: async (userId) => (await (await keys()).deleteOne({ userId: new ObjectId(userId) })).deletedCount === 1,
90
+ deleteFor: async (userId) => (await (await keys()).deleteMany({ userId: new ObjectId(userId) })).deletedCount,
91
+ ensureIndexes: async () => {
92
+ await buildIndexes(await db(), apiKeyIndexSpecs(collection));
93
+ },
94
+ };
95
+ };
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file src/check/cli.ts
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
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
+ */
11
+ /**
12
+ * @function runCheck
13
+ * @param root {string} the app's root (holds src/app and, optionally, content/)
14
+ * @param log {(line: string) => void} where lines go (default console.log)
15
+ * @returns {number} 0 when every standard passes, otherwise 1
16
+ */
17
+ export declare const runCheck: (root: string, log?: (line: string) => void) => number;
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file src/check/cli.ts
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
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
+ */
11
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
12
+ import { join, relative, sep } from "node:path";
13
+ import { argv, cwd, exit } from "node:process";
14
+ import { pathToFileURL } from "node:url";
15
+ import { checkStandards } from "./standards.js";
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("/"));
18
+ /**
19
+ * @function runCheck
20
+ * @param root {string} the app's root (holds src/app and, optionally, content/)
21
+ * @param log {(line: string) => void} where lines go (default console.log)
22
+ * @returns {number} 0 when every standard passes, otherwise 1
23
+ */
24
+ export const runCheck = (root, log = console.log) => {
25
+ const app = join(root, "src", "app");
26
+ if (!existsSync(app)) {
27
+ log(`next-kit check: no src/app in ${root}`);
28
+ return 1;
29
+ }
30
+ const contentDir = join(root, "content");
31
+ const contentFiles = existsSync(contentDir) ? relativeFiles(contentDir) : [];
32
+ const results = checkStandards(relativeFiles(app), contentFiles);
33
+ for (const result of results) {
34
+ log(`${result.ok ? "pass" : "FAIL"} ${result.label}`);
35
+ for (const missing of result.missing)
36
+ log(` missing ${missing}`);
37
+ }
38
+ return results.every((result) => result.ok) ? 0 : 1;
39
+ };
40
+ /* v8 ignore start */
41
+ /** True when this file is the one Node was asked to run, even through a symlinked bin: a
42
+ * symlinked `next-kit` resolves argv[1] to the link, while import.meta.url is the realpath. */
43
+ const isMainEntry = () => {
44
+ const entry = argv[1];
45
+ if (!entry || !existsSync(entry))
46
+ return false;
47
+ return pathToFileURL(realpathSync(entry)).href === import.meta.url;
48
+ };
49
+ if (isMainEntry()) {
50
+ const [command, dir] = argv.slice(2);
51
+ if (command !== "check") {
52
+ console.log("usage: next-kit check [dir]");
53
+ exit(2);
54
+ }
55
+ exit(runCheck(dir ?? cwd()));
56
+ }
57
+ /* v8 ignore stop */
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @file src/check/standards.ts
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.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Sat Oct 3, 2026
12
+ * @modified Sun Oct 4, 2026
13
+ */
14
+ /** One standard's outcome. `missing` entries carry their own prefix (src/app/... or content/...). */
15
+ export type StandardResult = {
16
+ id: string;
17
+ label: string;
18
+ ok: boolean;
19
+ missing: string[];
20
+ };
21
+ /**
22
+ * @function checkStandards
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
28
+ */
29
+ export declare const checkStandards: (appFiles: readonly string[], contentFiles?: readonly string[]) => StandardResult[];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @file src/check/standards.ts
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.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Sat Oct 3, 2026
12
+ * @modified Sun Oct 4, 2026
13
+ */
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
+ });
24
+ const CRAWL = [
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)$/),
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");
45
+ const API = [
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"),
50
+ ];
51
+ const evaluate = (id, label, reqs, app, files) => {
52
+ const missing = reqs.filter((req) => !req.matches(app, files)).map((req) => req.missing);
53
+ return { id, label, ok: missing.length === 0, missing };
54
+ };
55
+ /** Route groups like (public)/ don't change the URL, so they are dropped before matching. */
56
+ const ungrouped = (file) => file.replace(/(^|\/)\([^)]+\)(?=\/)/g, "").replace(/^\//, "");
57
+ /**
58
+ * @function checkStandards
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
64
+ */
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));
83
+ }
84
+ return results;
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
+ ];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @file src/docs/files/index.ts
3
+ * @desc @haruhimemoe/next-kit/docs/files: reads the markdown files a content registry's entries
4
+ * point at. Server only: loads node:fs (kept out of the pure `docs` entry point on
5
+ * purpose). A section's markdown source lives at "<root>/content/<section>/<slug>.mdx";
6
+ * `readContentMarkdown` converts one with `mdxToMarkdown`, and `contentFileDrift` compares
7
+ * the registry against the files actually on disk.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sun Oct 4, 2026
10
+ * @modified Sun Oct 4, 2026
11
+ */
12
+ import { type Content, type ContentSection, type MarkdownOptions } from "../index.js";
13
+ /**
14
+ * @function readContentMarkdown
15
+ * @param content {Content} a validated registry from `defineContent`
16
+ * @param section {ContentSection} the section the entry lives under
17
+ * @param slug {string} the entry's slug
18
+ * @param options {{ root?: string; siteUrl: string; transforms?: MarkdownOptions["transforms"] }}
19
+ * `root` defaults to `process.cwd()`; the source file is read from
20
+ * "<root>/content/<section>/<slug>.mdx"
21
+ * @returns {Promise<string | null>} the converted markdown, or null when the slug isn't
22
+ * registered in `content`
23
+ * @throws {Error} the file system's ENOENT when the slug is registered but its file is missing
24
+ */
25
+ export declare const readContentMarkdown: (content: Content, section: ContentSection, slug: string, options: {
26
+ root?: string;
27
+ siteUrl: string;
28
+ transforms?: MarkdownOptions["transforms"];
29
+ }) => Promise<string | null>;
30
+ /**
31
+ * @function contentFileDrift
32
+ * @param content {Content} a validated registry from `defineContent`
33
+ * @param options {{ root?: string }} `root` defaults to `process.cwd()`
34
+ * @returns {{ missingFiles: string[]; unregistered: string[] }} `missingFiles` lists every
35
+ * registered entry with no ".mdx" file on disk (like "guides/x.mdx"); `unregistered` lists
36
+ * every ".mdx" file on disk with no matching registry entry
37
+ */
38
+ export declare const contentFileDrift: (content: Content, options?: {
39
+ root?: string;
40
+ }) => {
41
+ missingFiles: string[];
42
+ unregistered: string[];
43
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @file src/docs/files/index.ts
3
+ * @desc @haruhimemoe/next-kit/docs/files: reads the markdown files a content registry's entries
4
+ * point at. Server only: loads node:fs (kept out of the pure `docs` entry point on
5
+ * purpose). A section's markdown source lives at "<root>/content/<section>/<slug>.mdx";
6
+ * `readContentMarkdown` converts one with `mdxToMarkdown`, and `contentFileDrift` compares
7
+ * the registry against the files actually on disk.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sun Oct 4, 2026
10
+ * @modified Sun Oct 4, 2026
11
+ */
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { readFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { CONTENT_SECTIONS, findEntry, mdxToMarkdown, } from "../index.js";
16
+ const MDX_EXTENSION = ".mdx";
17
+ /** Where an entry's source markdown lives, relative to root: "<section>/<slug>.mdx". */
18
+ const entryFile = (section, slug) => `${section}/${slug}${MDX_EXTENSION}`;
19
+ /**
20
+ * @function readContentMarkdown
21
+ * @param content {Content} a validated registry from `defineContent`
22
+ * @param section {ContentSection} the section the entry lives under
23
+ * @param slug {string} the entry's slug
24
+ * @param options {{ root?: string; siteUrl: string; transforms?: MarkdownOptions["transforms"] }}
25
+ * `root` defaults to `process.cwd()`; the source file is read from
26
+ * "<root>/content/<section>/<slug>.mdx"
27
+ * @returns {Promise<string | null>} the converted markdown, or null when the slug isn't
28
+ * registered in `content`
29
+ * @throws {Error} the file system's ENOENT when the slug is registered but its file is missing
30
+ */
31
+ export const readContentMarkdown = async (content, section, slug, options) => {
32
+ const entry = findEntry(content, section, slug);
33
+ if (!entry)
34
+ return null;
35
+ const root = options.root ?? process.cwd();
36
+ const source = await readFile(join(root, "content", entryFile(section, slug)), "utf8");
37
+ return mdxToMarkdown(source, {
38
+ title: entry.title,
39
+ siteUrl: options.siteUrl,
40
+ ...(options.transforms !== undefined ? { transforms: options.transforms } : {}),
41
+ });
42
+ };
43
+ /**
44
+ * @function contentFileDrift
45
+ * @param content {Content} a validated registry from `defineContent`
46
+ * @param options {{ root?: string }} `root` defaults to `process.cwd()`
47
+ * @returns {{ missingFiles: string[]; unregistered: string[] }} `missingFiles` lists every
48
+ * registered entry with no ".mdx" file on disk (like "guides/x.mdx"); `unregistered` lists
49
+ * every ".mdx" file on disk with no matching registry entry
50
+ */
51
+ export const contentFileDrift = (content, options = {}) => {
52
+ const root = options.root ?? process.cwd();
53
+ const missingFiles = [];
54
+ const unregistered = [];
55
+ for (const section of CONTENT_SECTIONS) {
56
+ const slugs = new Set(content.entries[section].map((entry) => entry.slug));
57
+ for (const slug of slugs) {
58
+ if (!existsSync(join(root, "content", entryFile(section, slug))))
59
+ missingFiles.push(entryFile(section, slug));
60
+ }
61
+ let files;
62
+ try {
63
+ files = readdirSync(join(root, "content", section));
64
+ }
65
+ catch {
66
+ files = [];
67
+ }
68
+ for (const file of files) {
69
+ if (file.endsWith(MDX_EXTENSION) && !slugs.has(file.slice(0, -MDX_EXTENSION.length)))
70
+ unregistered.push(`${section}/${file}`);
71
+ }
72
+ }
73
+ return { missingFiles, unregistered };
74
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @file src/docs/index.ts
3
+ * @desc @haruhimemoe/next-kit/docs: the content registry (sections, entries, app-made extras),
4
+ * the path helpers every docs, guides and legal page is built from, mdxToMarkdown's
5
+ * MDX-to-plain-Markdown conversion, and the crawl helpers (llms.txt, llms-full.txt, sitemap
6
+ * entries, the ".md" mirror rewrite) built from the registry. Pure: no node: imports, so it
7
+ * runs in any route, edge or browser. File-backed markdown reading lives under the separate
8
+ * `docs/files` entry point.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sun Oct 4, 2026
11
+ * @modified Sun Oct 4, 2026
12
+ */
13
+ export { type ContentApiLink, type ContentLlmsFullOptions, type ContentLlmsTxtOptions, type ContentRewriteRule, contentLlmsFull, contentLlmsTxt, contentRewrites, contentSitemap, } from "./crawl.js";
14
+ export { type MarkdownOptions, mdxToMarkdown } from "./markdown.js";
15
+ export { CONTENT_SECTIONS, type Content, type ContentEntry, type ContentInput, type ContentSection, contentParams, contentPath, defineContent, type ExtraEntry, findEntry, type HowToStep, markdownPath, SECTION_LABELS, } from "./registry.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @file src/docs/index.ts
3
+ * @desc @haruhimemoe/next-kit/docs: the content registry (sections, entries, app-made extras),
4
+ * the path helpers every docs, guides and legal page is built from, mdxToMarkdown's
5
+ * MDX-to-plain-Markdown conversion, and the crawl helpers (llms.txt, llms-full.txt, sitemap
6
+ * entries, the ".md" mirror rewrite) built from the registry. Pure: no node: imports, so it
7
+ * runs in any route, edge or browser. File-backed markdown reading lives under the separate
8
+ * `docs/files` entry point.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sun Oct 4, 2026
11
+ * @modified Sun Oct 4, 2026
12
+ */
13
+ export { contentLlmsFull, contentLlmsTxt, contentRewrites, contentSitemap, } from "./crawl.js";
14
+ export { mdxToMarkdown } from "./markdown.js";
15
+ export { CONTENT_SECTIONS, contentParams, contentPath, defineContent, findEntry, markdownPath, SECTION_LABELS, } from "./registry.js";