@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.
- package/CHANGELOG.md +25 -1
- package/README.md +143 -3
- package/dist/api-keys/format.d.ts +56 -0
- package/dist/api-keys/format.js +67 -0
- package/dist/api-keys/guard.d.ts +74 -0
- package/dist/api-keys/guard.js +90 -0
- package/dist/api-keys/index.d.ts +12 -0
- package/dist/api-keys/index.js +12 -0
- package/dist/api-keys/store.d.ts +64 -0
- package/dist/api-keys/store.js +95 -0
- package/dist/check/cli.d.ts +17 -0
- package/dist/check/cli.js +57 -0
- package/dist/check/standards.d.ts +29 -0
- package/dist/check/standards.js +85 -0
- 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/dist/server/security-txt.d.ts +7 -5
- package/dist/server/security-txt.js +6 -5
- package/package.json +20 -4
|
@@ -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";
|