@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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/docs/markdown-segments.ts
|
|
3
|
+
* @desc Fence-aware line segmenting for mdxToMarkdown, split out to keep markdown.ts under 200
|
|
4
|
+
* lines: splits text into fence and prose runs (a fence may be indented, e.g. under a list
|
|
5
|
+
* item), and merges a <Callout> opened in one prose segment with its closing tag in a
|
|
6
|
+
* later segment (because its body holds a fenced code block) into one already-converted
|
|
7
|
+
* blockquote segment. Pure: no node: imports.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** One run of consecutive lines, either inside a fence (left alone) or prose (converted). */
|
|
13
|
+
export type Segment = {
|
|
14
|
+
readonly isFence: boolean;
|
|
15
|
+
readonly lines: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @function segmentFences
|
|
19
|
+
* @param text {string} the MDX prose, after transforms and newline normalization
|
|
20
|
+
* @returns {Segment[]} fence and prose runs, in order; a fence's opening indentation is not
|
|
21
|
+
* required on its closing line, only the same marker character repeated at least as many times
|
|
22
|
+
*/
|
|
23
|
+
export declare const segmentFences: (text: string) => Segment[];
|
|
24
|
+
/**
|
|
25
|
+
* @function mergeCalloutSegments
|
|
26
|
+
* @param segments {Segment[]} the result of `segmentFences`
|
|
27
|
+
* @param processBodyProse {(text: string) => string} rules 4/5 (JSX removal, link absolutizing),
|
|
28
|
+
* run on every non-fence chunk of a merged callout's body before it is quoted; fence chunks of
|
|
29
|
+
* the body are passed to `toBlockquote` untouched
|
|
30
|
+
* @param toBlockquote {(attrs: string, body: string) => string} rule 3's attrs+body-to-blockquote
|
|
31
|
+
* converter
|
|
32
|
+
* @returns {Segment[]} the same segments, except a `<Callout>` opened in one prose segment and
|
|
33
|
+
* closed in a later one (its body holds a fenced code block, so `segmentFences` split it out)
|
|
34
|
+
* becomes a single, already-converted blockquote segment marked `isFence: true` so the caller
|
|
35
|
+
* does not run prose rules over it again
|
|
36
|
+
*/
|
|
37
|
+
export declare const mergeCalloutSegments: (segments: Segment[], processBodyProse: (text: string) => string, toBlockquote: (attrs: string, body: string) => string) => Segment[];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/docs/markdown-segments.ts
|
|
3
|
+
* @desc Fence-aware line segmenting for mdxToMarkdown, split out to keep markdown.ts under 200
|
|
4
|
+
* lines: splits text into fence and prose runs (a fence may be indented, e.g. under a list
|
|
5
|
+
* item), and merges a <Callout> opened in one prose segment with its closing tag in a
|
|
6
|
+
* later segment (because its body holds a fenced code block) into one already-converted
|
|
7
|
+
* blockquote segment. Pure: no node: imports.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
const FENCE_OPEN = /^(\s*)(`{3,}|~{3,})/;
|
|
13
|
+
const CALLOUT_OPEN_OR_CLOSE = /<Callout([^>]*)>|<\/Callout>/g;
|
|
14
|
+
const CALLOUT_CLOSE_TAG = "</Callout>";
|
|
15
|
+
/**
|
|
16
|
+
* @function segmentFences
|
|
17
|
+
* @param text {string} the MDX prose, after transforms and newline normalization
|
|
18
|
+
* @returns {Segment[]} fence and prose runs, in order; a fence's opening indentation is not
|
|
19
|
+
* required on its closing line, only the same marker character repeated at least as many times
|
|
20
|
+
*/
|
|
21
|
+
export const segmentFences = (text) => {
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
const segments = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < lines.length) {
|
|
26
|
+
const line = lines[i];
|
|
27
|
+
if (line === undefined)
|
|
28
|
+
break;
|
|
29
|
+
const open = line.match(FENCE_OPEN);
|
|
30
|
+
if (!open) {
|
|
31
|
+
const prose = [];
|
|
32
|
+
while (i < lines.length) {
|
|
33
|
+
const proseLine = lines[i];
|
|
34
|
+
if (proseLine === undefined || FENCE_OPEN.test(proseLine))
|
|
35
|
+
break;
|
|
36
|
+
prose.push(proseLine);
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
segments.push({ isFence: false, lines: prose });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const marker = open[2]?.[0] ?? "`";
|
|
43
|
+
const minLength = open[2]?.length ?? 3;
|
|
44
|
+
const close = new RegExp(`^\\s*${marker}{${minLength},}\\s*$`);
|
|
45
|
+
const fence = [line];
|
|
46
|
+
i++;
|
|
47
|
+
while (i < lines.length) {
|
|
48
|
+
const fenceLine = lines[i];
|
|
49
|
+
if (fenceLine === undefined)
|
|
50
|
+
break;
|
|
51
|
+
fence.push(fenceLine);
|
|
52
|
+
const isClose = close.test(fenceLine);
|
|
53
|
+
i++;
|
|
54
|
+
if (isClose)
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
segments.push({ isFence: true, lines: fence });
|
|
58
|
+
}
|
|
59
|
+
return segments;
|
|
60
|
+
};
|
|
61
|
+
/** Finds a trailing `<Callout>` open tag in `text` with no matching `</Callout>` after it. */
|
|
62
|
+
const findUnmatchedCallout = (text) => {
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let pending = null;
|
|
65
|
+
CALLOUT_OPEN_OR_CLOSE.lastIndex = 0;
|
|
66
|
+
let match = CALLOUT_OPEN_OR_CLOSE.exec(text);
|
|
67
|
+
while (match !== null) {
|
|
68
|
+
if (match[1] !== undefined) {
|
|
69
|
+
if (depth === 0) {
|
|
70
|
+
pending = { start: match.index, end: match.index + match[0].length, attrs: match[1] };
|
|
71
|
+
}
|
|
72
|
+
depth++;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
depth = Math.max(0, depth - 1);
|
|
76
|
+
if (depth === 0)
|
|
77
|
+
pending = null;
|
|
78
|
+
}
|
|
79
|
+
match = CALLOUT_OPEN_OR_CLOSE.exec(text);
|
|
80
|
+
}
|
|
81
|
+
return depth > 0 ? pending : null;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* @function mergeCalloutSegments
|
|
85
|
+
* @param segments {Segment[]} the result of `segmentFences`
|
|
86
|
+
* @param processBodyProse {(text: string) => string} rules 4/5 (JSX removal, link absolutizing),
|
|
87
|
+
* run on every non-fence chunk of a merged callout's body before it is quoted; fence chunks of
|
|
88
|
+
* the body are passed to `toBlockquote` untouched
|
|
89
|
+
* @param toBlockquote {(attrs: string, body: string) => string} rule 3's attrs+body-to-blockquote
|
|
90
|
+
* converter
|
|
91
|
+
* @returns {Segment[]} the same segments, except a `<Callout>` opened in one prose segment and
|
|
92
|
+
* closed in a later one (its body holds a fenced code block, so `segmentFences` split it out)
|
|
93
|
+
* becomes a single, already-converted blockquote segment marked `isFence: true` so the caller
|
|
94
|
+
* does not run prose rules over it again
|
|
95
|
+
*/
|
|
96
|
+
export const mergeCalloutSegments = (segments, processBodyProse, toBlockquote) => {
|
|
97
|
+
const merged = [];
|
|
98
|
+
let attrs = null;
|
|
99
|
+
let chunks = null;
|
|
100
|
+
for (const segment of segments) {
|
|
101
|
+
if (chunks !== null) {
|
|
102
|
+
const text = segment.lines.join("\n");
|
|
103
|
+
const closeAt = segment.isFence ? -1 : text.indexOf(CALLOUT_CLOSE_TAG);
|
|
104
|
+
if (closeAt === -1) {
|
|
105
|
+
chunks.push({ isFence: segment.isFence, text });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const bodyPart = text.slice(0, closeAt);
|
|
109
|
+
const remainder = text.slice(closeAt + CALLOUT_CLOSE_TAG.length);
|
|
110
|
+
if (bodyPart.length > 0)
|
|
111
|
+
chunks.push({ isFence: false, text: bodyPart });
|
|
112
|
+
const body = chunks
|
|
113
|
+
.map((chunk) => (chunk.isFence ? chunk.text : processBodyProse(chunk.text)))
|
|
114
|
+
.join("\n");
|
|
115
|
+
const blockquote = toBlockquote(attrs ?? "", body);
|
|
116
|
+
merged.push({ isFence: true, lines: blockquote.split("\n") });
|
|
117
|
+
chunks = null;
|
|
118
|
+
attrs = null;
|
|
119
|
+
if (remainder.length > 0) {
|
|
120
|
+
const rest = mergeCalloutSegments([{ isFence: false, lines: remainder.split("\n") }], processBodyProse, toBlockquote);
|
|
121
|
+
merged.push(...rest);
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (segment.isFence) {
|
|
126
|
+
merged.push(segment);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const text = segment.lines.join("\n");
|
|
130
|
+
const unmatched = findUnmatchedCallout(text);
|
|
131
|
+
if (!unmatched) {
|
|
132
|
+
merged.push(segment);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const before = text.slice(0, unmatched.start);
|
|
136
|
+
if (before.length > 0)
|
|
137
|
+
merged.push({ isFence: false, lines: before.split("\n") });
|
|
138
|
+
attrs = unmatched.attrs;
|
|
139
|
+
chunks = [{ isFence: false, text: text.slice(unmatched.end) }];
|
|
140
|
+
}
|
|
141
|
+
if (chunks !== null) {
|
|
142
|
+
// Unterminated callout (malformed input): best-effort passthrough, raw.
|
|
143
|
+
const body = chunks.map((chunk) => chunk.text).join("\n");
|
|
144
|
+
merged.push({ isFence: false, lines: body.split("\n") });
|
|
145
|
+
}
|
|
146
|
+
return merged;
|
|
147
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/docs/markdown.ts
|
|
3
|
+
* @desc Converts bb-flavored MDX (<Callout>, capitalized JSX like <Example>, import/export lines)
|
|
4
|
+
* into plain Markdown for an app's generated .md pages: callouts become blockquotes,
|
|
5
|
+
* import/export lines are dropped, root-relative links and images become absolute with the
|
|
6
|
+
* site's origin, and a missing title heading is added. Pure: no node: imports, so it runs
|
|
7
|
+
* anywhere. Everything inside a fenced code block (``` or ~~~, 3+ characters, matched close,
|
|
8
|
+
* optionally indented) is left exactly as written; a `<Callout>` whose body holds one of
|
|
9
|
+
* those fences still converts to a blockquote (see markdown-segments.ts).
|
|
10
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
11
|
+
* @created Sun Oct 4, 2026
|
|
12
|
+
* @modified Sun Oct 4, 2026
|
|
13
|
+
*/
|
|
14
|
+
/** Options for `mdxToMarkdown`. */
|
|
15
|
+
export type MarkdownOptions = {
|
|
16
|
+
title: string;
|
|
17
|
+
siteUrl: string;
|
|
18
|
+
/** Run first, over the whole raw source (bb's <Example>). */
|
|
19
|
+
transforms?: readonly ((source: string) => string)[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* @function mdxToMarkdown
|
|
23
|
+
* @param source {string} the raw MDX source
|
|
24
|
+
* @param options {MarkdownOptions} the fallback title, the site's origin for root-relative
|
|
25
|
+
* links and images, and any transforms to run first over the whole source
|
|
26
|
+
* @returns {string} the converted Markdown: content inside fenced code blocks is untouched,
|
|
27
|
+
* import/export lines and other capitalized JSX are removed, callouts become blockquotes,
|
|
28
|
+
* root-relative links and images become absolute, a title heading is added when missing, and
|
|
29
|
+
* the result ends with exactly one trailing newline
|
|
30
|
+
*/
|
|
31
|
+
export declare const mdxToMarkdown: (source: string, options: MarkdownOptions) => string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/docs/markdown.ts
|
|
3
|
+
* @desc Converts bb-flavored MDX (<Callout>, capitalized JSX like <Example>, import/export lines)
|
|
4
|
+
* into plain Markdown for an app's generated .md pages: callouts become blockquotes,
|
|
5
|
+
* import/export lines are dropped, root-relative links and images become absolute with the
|
|
6
|
+
* site's origin, and a missing title heading is added. Pure: no node: imports, so it runs
|
|
7
|
+
* anywhere. Everything inside a fenced code block (``` or ~~~, 3+ characters, matched close,
|
|
8
|
+
* optionally indented) is left exactly as written; a `<Callout>` whose body holds one of
|
|
9
|
+
* those fences still converts to a blockquote (see markdown-segments.ts).
|
|
10
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
11
|
+
* @created Sun Oct 4, 2026
|
|
12
|
+
* @modified Sun Oct 4, 2026
|
|
13
|
+
*/
|
|
14
|
+
import { mergeCalloutSegments, segmentFences } from "./markdown-segments.js";
|
|
15
|
+
const EXPORT_OBJECT_OPEN = /^export\s+const\s+\S+\s*=\s*\{\s*$/;
|
|
16
|
+
const EXPORT_OBJECT_CLOSE = /^\}\s*;?\s*$/;
|
|
17
|
+
const IMPORT_OR_EXPORT = /^(?:import|export)\b/;
|
|
18
|
+
const CALLOUT = /<Callout([^>]*)>([\s\S]*?)<\/Callout>/g;
|
|
19
|
+
const CALLOUT_TYPE = /\btype="([^"]*)"/;
|
|
20
|
+
const JSX_TAG = /<\/?[A-Z][\w.]*(?:\s[^<>]*)?\s*\/?>/g;
|
|
21
|
+
const ROOT_LINK_TARGET = /\]\(\/(?!\/)([^)]*)\)/g;
|
|
22
|
+
const titleCase = (value) => value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
|
23
|
+
/** Rule 2: drops top-level import/export lines, and an export const object block. */
|
|
24
|
+
const dropImportExport = (text) => {
|
|
25
|
+
const lines = text.split("\n");
|
|
26
|
+
const kept = [];
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < lines.length) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
if (line === undefined)
|
|
31
|
+
break;
|
|
32
|
+
if (EXPORT_OBJECT_OPEN.test(line)) {
|
|
33
|
+
i++;
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const blockLine = lines[i];
|
|
36
|
+
if (blockLine === undefined || EXPORT_OBJECT_CLOSE.test(blockLine))
|
|
37
|
+
break;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (IMPORT_OR_EXPORT.test(line)) {
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
kept.push(line);
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return kept.join("\n");
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Rule 3: turns a `<Callout type="x" title="...">body</Callout>` match's attrs and body into a
|
|
54
|
+
* blockquote: the label on the first line, every other body line (fence lines included, for a
|
|
55
|
+
* callout whose body holds a fenced code block) prefixed with `> `.
|
|
56
|
+
*/
|
|
57
|
+
const calloutBodyToBlockquote = (attrs, body) => {
|
|
58
|
+
const type = CALLOUT_TYPE.exec(attrs)?.[1] ?? "note";
|
|
59
|
+
const label = titleCase(type);
|
|
60
|
+
const lines = body.replace(/^\n+/, "").replace(/\n+$/, "").split("\n");
|
|
61
|
+
const [first = "", ...rest] = lines;
|
|
62
|
+
return [`> **${label}:** ${first}`, ...rest.map((line) => `> ${line}`)].join("\n");
|
|
63
|
+
};
|
|
64
|
+
/** Rule 3: <Callout type="x" title="...">body</Callout> becomes a blockquote. */
|
|
65
|
+
const convertCallouts = (text) => text.replace(CALLOUT, (_match, attrs, body) => calloutBodyToBlockquote(attrs, body));
|
|
66
|
+
/** Rule 4: removes any other capitalized JSX tag, keeping the text between tags. */
|
|
67
|
+
const removeJsxTags = (text) => text.replace(JSX_TAG, "");
|
|
68
|
+
/** Rule 5: a link or image target starting with a single "/" becomes absolute. */
|
|
69
|
+
const absolutizeRootLinks = (text, siteUrl) => text.replace(ROOT_LINK_TARGET, (_match, target) => `](${siteUrl}/${target})`);
|
|
70
|
+
const processProse = (text, siteUrl) => absolutizeRootLinks(removeJsxTags(convertCallouts(dropImportExport(text))), siteUrl);
|
|
71
|
+
/** Rule 6: prepends the title heading when the first non-blank line isn't one. */
|
|
72
|
+
const ensureTitle = (text, title) => {
|
|
73
|
+
const firstContentLine = text.split("\n").find((line) => line.trim() !== "");
|
|
74
|
+
return firstContentLine?.startsWith("# ") ? text : `# ${title}\n\n${text}`;
|
|
75
|
+
};
|
|
76
|
+
/** Rule 7: collapses runs of 3+ blank lines to 2, then trims to exactly one trailing newline. */
|
|
77
|
+
const finalize = (text) => {
|
|
78
|
+
const lines = text.split("\n");
|
|
79
|
+
const kept = [];
|
|
80
|
+
let blankRun = 0;
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.trim() === "") {
|
|
83
|
+
blankRun++;
|
|
84
|
+
if (blankRun <= 2)
|
|
85
|
+
kept.push(line);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
blankRun = 0;
|
|
89
|
+
kept.push(line);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return `${kept.join("\n").trim()}\n`;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* @function mdxToMarkdown
|
|
96
|
+
* @param source {string} the raw MDX source
|
|
97
|
+
* @param options {MarkdownOptions} the fallback title, the site's origin for root-relative
|
|
98
|
+
* links and images, and any transforms to run first over the whole source
|
|
99
|
+
* @returns {string} the converted Markdown: content inside fenced code blocks is untouched,
|
|
100
|
+
* import/export lines and other capitalized JSX are removed, callouts become blockquotes,
|
|
101
|
+
* root-relative links and images become absolute, a title heading is added when missing, and
|
|
102
|
+
* the result ends with exactly one trailing newline
|
|
103
|
+
*/
|
|
104
|
+
export const mdxToMarkdown = (source, options) => {
|
|
105
|
+
const { title, siteUrl, transforms = [] } = options;
|
|
106
|
+
let text = source.replace(/\r\n?/g, "\n");
|
|
107
|
+
for (const transform of transforms)
|
|
108
|
+
text = transform(text);
|
|
109
|
+
text = text.replace(/\r\n?/g, "\n");
|
|
110
|
+
// Rules 4/5 only, run on a fence-spanning callout's non-fence body chunks before quoting; see
|
|
111
|
+
// mergeCalloutSegments's doc comment for why fence chunks skip this.
|
|
112
|
+
const processBodyProse = (body) => absolutizeRootLinks(removeJsxTags(body), siteUrl);
|
|
113
|
+
const converted = mergeCalloutSegments(segmentFences(text), processBodyProse, calloutBodyToBlockquote)
|
|
114
|
+
.map((segment) => segment.isFence ? segment.lines.join("\n") : processProse(segment.lines.join("\n"), siteUrl))
|
|
115
|
+
.join("\n");
|
|
116
|
+
return finalize(ensureTitle(converted, title));
|
|
117
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/docs/registry.ts
|
|
3
|
+
* @desc The content registry every haruhime.moe site builds its docs, guides and legal pages
|
|
4
|
+
* from: a frozen list of sections, the entry shape each page fills in, the app-made extra
|
|
5
|
+
* entries a section's nav and search also carry (like bb's tag pages), and the validation
|
|
6
|
+
* that catches a bad slug, a duplicate, a made-up date or a blank title at build time
|
|
7
|
+
* instead of at a 404. Pure: no node: imports, so it runs anywhere.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** Every content section a haruhime.moe site can have, in the order it's shown. */
|
|
13
|
+
export declare const CONTENT_SECTIONS: readonly ["docs", "guides", "legal"];
|
|
14
|
+
/** One of `CONTENT_SECTIONS`. */
|
|
15
|
+
export type ContentSection = (typeof CONTENT_SECTIONS)[number];
|
|
16
|
+
/** The nav label for each section. */
|
|
17
|
+
export declare const SECTION_LABELS: Record<ContentSection, string>;
|
|
18
|
+
/** One numbered step of a HowTo entry. */
|
|
19
|
+
export type HowToStep = {
|
|
20
|
+
name: string;
|
|
21
|
+
text: string;
|
|
22
|
+
};
|
|
23
|
+
/** A page backed by a markdown file: its slug, nav copy and when it last changed. */
|
|
24
|
+
export type ContentEntry = {
|
|
25
|
+
slug: string;
|
|
26
|
+
title: string;
|
|
27
|
+
navTitle?: string;
|
|
28
|
+
description: string;
|
|
29
|
+
/** YYYY-MM-DD. */
|
|
30
|
+
lastUpdated: string;
|
|
31
|
+
howTo?: readonly HowToStep[];
|
|
32
|
+
};
|
|
33
|
+
/** An app-made page shown in a section's nav and search, e.g. bb's tag pages. */
|
|
34
|
+
export type ExtraEntry = {
|
|
35
|
+
href: string;
|
|
36
|
+
title: string;
|
|
37
|
+
navTitle?: string;
|
|
38
|
+
description: string;
|
|
39
|
+
group: string;
|
|
40
|
+
badge?: string;
|
|
41
|
+
lastUpdated?: string;
|
|
42
|
+
markdownHref?: string;
|
|
43
|
+
};
|
|
44
|
+
/** What an app passes to `defineContent`: its entries and extras, by section. */
|
|
45
|
+
export type ContentInput = Partial<Record<ContentSection, readonly ContentEntry[]>> & {
|
|
46
|
+
extra?: Partial<Record<ContentSection, readonly ExtraEntry[]>>;
|
|
47
|
+
};
|
|
48
|
+
/** A validated, section-complete content registry. */
|
|
49
|
+
export type Content = {
|
|
50
|
+
/** Non-empty sections, in `CONTENT_SECTIONS` order. */
|
|
51
|
+
sections: ContentSection[];
|
|
52
|
+
entries: Record<ContentSection, readonly ContentEntry[]>;
|
|
53
|
+
extra: Record<ContentSection, readonly ExtraEntry[]>;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* @function defineContent
|
|
57
|
+
* @param input {ContentInput} each section's entries, and the app-made extras under `extra`
|
|
58
|
+
* @returns {Content} every section filled in (empty arrays for the ones left out), and
|
|
59
|
+
* `sections` listing only the non-empty ones, in `CONTENT_SECTIONS` order
|
|
60
|
+
* @throws {Error} naming the section and slug or extra href, for a bad slug, a duplicate slug,
|
|
61
|
+
* a duplicate extra href, a `lastUpdated` that isn't a real `YYYY-MM-DD` date, or a blank title
|
|
62
|
+
*/
|
|
63
|
+
export declare const defineContent: (input: ContentInput) => Content;
|
|
64
|
+
/**
|
|
65
|
+
* @function contentPath
|
|
66
|
+
* @param section {ContentSection} the section the page lives under
|
|
67
|
+
* @param slug {string} the entry's slug
|
|
68
|
+
* @returns {string} the page's path, like "/guides/make-a-pack"
|
|
69
|
+
*/
|
|
70
|
+
export declare const contentPath: (section: ContentSection, slug: string) => string;
|
|
71
|
+
/**
|
|
72
|
+
* @function markdownPath
|
|
73
|
+
* @param section {ContentSection} the section the page lives under
|
|
74
|
+
* @param slug {string} the entry's slug
|
|
75
|
+
* @returns {string} the page's raw markdown path, like "/guides/make-a-pack.md"
|
|
76
|
+
*/
|
|
77
|
+
export declare const markdownPath: (section: ContentSection, slug: string) => string;
|
|
78
|
+
/**
|
|
79
|
+
* @function findEntry
|
|
80
|
+
* @param content {Content} a registry from `defineContent`
|
|
81
|
+
* @param section {ContentSection} the section to search
|
|
82
|
+
* @param slug {string} the entry's slug
|
|
83
|
+
* @returns {ContentEntry | undefined} the matching entry, or undefined
|
|
84
|
+
*/
|
|
85
|
+
export declare const findEntry: (content: Content, section: ContentSection, slug: string) => ContentEntry | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* @function contentParams
|
|
88
|
+
* @param content {Content} a registry from `defineContent`
|
|
89
|
+
* @param section {ContentSection} the section to list
|
|
90
|
+
* @returns {{ slug: string }[]} one param per entry, for a dynamic route's `generateStaticParams`
|
|
91
|
+
*/
|
|
92
|
+
export declare const contentParams: (content: Content, section: ContentSection) => {
|
|
93
|
+
slug: string;
|
|
94
|
+
}[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/docs/registry.ts
|
|
3
|
+
* @desc The content registry every haruhime.moe site builds its docs, guides and legal pages
|
|
4
|
+
* from: a frozen list of sections, the entry shape each page fills in, the app-made extra
|
|
5
|
+
* entries a section's nav and search also carry (like bb's tag pages), and the validation
|
|
6
|
+
* that catches a bad slug, a duplicate, a made-up date or a blank title at build time
|
|
7
|
+
* instead of at a 404. Pure: no node: imports, so it runs anywhere.
|
|
8
|
+
* @author David @dvhsh (https://dvh.sh)
|
|
9
|
+
* @created Sun Oct 4, 2026
|
|
10
|
+
* @modified Sun Oct 4, 2026
|
|
11
|
+
*/
|
|
12
|
+
/** A slug is lowercase words separated by single hyphens, like "make-a-pack". */
|
|
13
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
14
|
+
/** A date is YYYY-MM-DD and a real calendar day. */
|
|
15
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
16
|
+
/** Every content section a haruhime.moe site can have, in the order it's shown. */
|
|
17
|
+
export const CONTENT_SECTIONS = ["docs", "guides", "legal"];
|
|
18
|
+
/** The nav label for each section. */
|
|
19
|
+
export const SECTION_LABELS = {
|
|
20
|
+
docs: "Docs",
|
|
21
|
+
guides: "Guides",
|
|
22
|
+
legal: "Legal",
|
|
23
|
+
};
|
|
24
|
+
const assertEntries = (section, entries) => {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (!SLUG_PATTERN.test(entry.slug))
|
|
28
|
+
throw new Error(`docs: bad slug "${entry.slug}" in ${section}`);
|
|
29
|
+
if (seen.has(entry.slug))
|
|
30
|
+
throw new Error(`docs: duplicate slug "${entry.slug}" in ${section}`);
|
|
31
|
+
seen.add(entry.slug);
|
|
32
|
+
if (!DATE_PATTERN.test(entry.lastUpdated) || Number.isNaN(Date.parse(entry.lastUpdated)))
|
|
33
|
+
throw new Error(`docs: bad lastUpdated "${entry.lastUpdated}" in ${section} "${entry.slug}"`);
|
|
34
|
+
if (!entry.title.trim())
|
|
35
|
+
throw new Error(`docs: blank title in ${section} "${entry.slug}"`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const assertExtras = (section, extras) => {
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const extra of extras) {
|
|
41
|
+
if (!extra.href.startsWith("/"))
|
|
42
|
+
throw new Error(`docs: extra href "${extra.href}" must start with "/" in ${section}`);
|
|
43
|
+
if (seen.has(extra.href))
|
|
44
|
+
throw new Error(`docs: duplicate extra href "${extra.href}" in ${section}`);
|
|
45
|
+
seen.add(extra.href);
|
|
46
|
+
if (!extra.title.trim())
|
|
47
|
+
throw new Error(`docs: blank title for extra "${extra.href}" in ${section}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* @function defineContent
|
|
52
|
+
* @param input {ContentInput} each section's entries, and the app-made extras under `extra`
|
|
53
|
+
* @returns {Content} every section filled in (empty arrays for the ones left out), and
|
|
54
|
+
* `sections` listing only the non-empty ones, in `CONTENT_SECTIONS` order
|
|
55
|
+
* @throws {Error} naming the section and slug or extra href, for a bad slug, a duplicate slug,
|
|
56
|
+
* a duplicate extra href, a `lastUpdated` that isn't a real `YYYY-MM-DD` date, or a blank title
|
|
57
|
+
*/
|
|
58
|
+
export const defineContent = (input) => {
|
|
59
|
+
const entries = {};
|
|
60
|
+
const extra = {};
|
|
61
|
+
const sections = [];
|
|
62
|
+
for (const section of CONTENT_SECTIONS) {
|
|
63
|
+
const sectionEntries = input[section] ?? [];
|
|
64
|
+
const sectionExtras = input.extra?.[section] ?? [];
|
|
65
|
+
assertEntries(section, sectionEntries);
|
|
66
|
+
assertExtras(section, sectionExtras);
|
|
67
|
+
entries[section] = sectionEntries;
|
|
68
|
+
extra[section] = sectionExtras;
|
|
69
|
+
if (sectionEntries.length > 0 || sectionExtras.length > 0)
|
|
70
|
+
sections.push(section);
|
|
71
|
+
}
|
|
72
|
+
return { sections, entries, extra };
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* @function contentPath
|
|
76
|
+
* @param section {ContentSection} the section the page lives under
|
|
77
|
+
* @param slug {string} the entry's slug
|
|
78
|
+
* @returns {string} the page's path, like "/guides/make-a-pack"
|
|
79
|
+
*/
|
|
80
|
+
export const contentPath = (section, slug) => `/${section}/${slug}`;
|
|
81
|
+
/**
|
|
82
|
+
* @function markdownPath
|
|
83
|
+
* @param section {ContentSection} the section the page lives under
|
|
84
|
+
* @param slug {string} the entry's slug
|
|
85
|
+
* @returns {string} the page's raw markdown path, like "/guides/make-a-pack.md"
|
|
86
|
+
*/
|
|
87
|
+
export const markdownPath = (section, slug) => `${contentPath(section, slug)}.md`;
|
|
88
|
+
/**
|
|
89
|
+
* @function findEntry
|
|
90
|
+
* @param content {Content} a registry from `defineContent`
|
|
91
|
+
* @param section {ContentSection} the section to search
|
|
92
|
+
* @param slug {string} the entry's slug
|
|
93
|
+
* @returns {ContentEntry | undefined} the matching entry, or undefined
|
|
94
|
+
*/
|
|
95
|
+
export const findEntry = (content, section, slug) => content.entries[section].find((entry) => entry.slug === slug);
|
|
96
|
+
/**
|
|
97
|
+
* @function contentParams
|
|
98
|
+
* @param content {Content} a registry from `defineContent`
|
|
99
|
+
* @param section {ContentSection} the section to list
|
|
100
|
+
* @returns {{ slug: string }[]} one param per entry, for a dynamic route's `generateStaticParams`
|
|
101
|
+
*/
|
|
102
|
+
export const contentParams = (content, section) => content.entries[section].map((entry) => ({ slug: entry.slug }));
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* caller passes them here.
|
|
7
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
8
|
* @created Mon Sep 28, 2026
|
|
9
|
-
* @modified
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
10
|
*/
|
|
11
11
|
/** RFC 9116 asks for an Expires less than a year out. */
|
|
12
12
|
export declare const SECURITY_TXT_LIFETIME_DAYS = 365;
|
|
@@ -22,11 +22,13 @@ export type SecurityTxtOptions = {
|
|
|
22
22
|
policyUrl: string;
|
|
23
23
|
/** When the file is built (build time for a static route). */
|
|
24
24
|
now: Date;
|
|
25
|
+
/** A preferred report URL (like a GitHub advisory form), listed before the email. */
|
|
26
|
+
contactUrl?: string;
|
|
25
27
|
};
|
|
26
28
|
/**
|
|
27
29
|
* @function buildSecurityTxt
|
|
28
|
-
* @param options {SecurityTxtOptions} contact, site, policy and
|
|
29
|
-
* @returns {string} the security.txt body: Contact
|
|
30
|
-
* Policy, one field per line, ending in one newline
|
|
30
|
+
* @param options {SecurityTxtOptions} contact, site, policy, build time, and optional contact URL
|
|
31
|
+
* @returns {string} the security.txt body: Contact (URL first if present), Contact (mailto),
|
|
32
|
+
* Expires, Preferred-Languages, Canonical, Policy, one field per line, ending in one newline
|
|
31
33
|
*/
|
|
32
|
-
export declare const buildSecurityTxt: ({ contactEmail, siteUrl, policyUrl, now, }: SecurityTxtOptions) => string;
|
|
34
|
+
export declare const buildSecurityTxt: ({ contactEmail, siteUrl, policyUrl, now, contactUrl, }: SecurityTxtOptions) => string;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* caller passes them here.
|
|
7
7
|
* @author David @dvhsh (https://dvh.sh)
|
|
8
8
|
* @created Mon Sep 28, 2026
|
|
9
|
-
* @modified
|
|
9
|
+
* @modified Sat Oct 3, 2026
|
|
10
10
|
*/
|
|
11
11
|
/** RFC 9116 asks for an Expires less than a year out. */
|
|
12
12
|
export const SECURITY_TXT_LIFETIME_DAYS = 365;
|
|
@@ -14,13 +14,14 @@ export const SECURITY_TXT_LIFETIME_DAYS = 365;
|
|
|
14
14
|
export const SECURITY_TXT_PATH = "/.well-known/security.txt";
|
|
15
15
|
/**
|
|
16
16
|
* @function buildSecurityTxt
|
|
17
|
-
* @param options {SecurityTxtOptions} contact, site, policy and
|
|
18
|
-
* @returns {string} the security.txt body: Contact
|
|
19
|
-
* Policy, one field per line, ending in one newline
|
|
17
|
+
* @param options {SecurityTxtOptions} contact, site, policy, build time, and optional contact URL
|
|
18
|
+
* @returns {string} the security.txt body: Contact (URL first if present), Contact (mailto),
|
|
19
|
+
* Expires, Preferred-Languages, Canonical, Policy, one field per line, ending in one newline
|
|
20
20
|
*/
|
|
21
|
-
export const buildSecurityTxt = ({ contactEmail, siteUrl, policyUrl, now, }) => {
|
|
21
|
+
export const buildSecurityTxt = ({ contactEmail, siteUrl, policyUrl, now, contactUrl, }) => {
|
|
22
22
|
const expires = new Date(now.getTime() + SECURITY_TXT_LIFETIME_DAYS * 86_400_000);
|
|
23
23
|
const lines = [
|
|
24
|
+
...(contactUrl ? [`Contact: ${contactUrl}`] : []),
|
|
24
25
|
`Contact: mailto:${contactEmail}`,
|
|
25
26
|
`Expires: ${expires.toISOString()}`,
|
|
26
27
|
"Preferred-Languages: en",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haruhimemoe/next-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The Next.js plumbing the haruhime.moe tools share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, the signed-in marker and account store for the browser,
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "The Next.js plumbing the haruhime.moe tools share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, the signed-in marker and account store for the browser, SEO: metadata, robots.txt, sitemaps, JSON-LD and llms.txt, and per-app API keys and the /api/v1 guard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nextjs",
|
|
7
7
|
"next.js",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"seo",
|
|
15
15
|
"json-ld",
|
|
16
16
|
"sitemap",
|
|
17
|
-
"llms.txt"
|
|
17
|
+
"llms.txt",
|
|
18
|
+
"api keys"
|
|
18
19
|
],
|
|
19
20
|
"homepage": "https://github.com/haruhimemoe/next-kit#readme",
|
|
20
21
|
"bugs": "https://github.com/haruhimemoe/next-kit/issues",
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
"license": "MIT",
|
|
26
27
|
"author": "David (https://dvh.sh)",
|
|
27
28
|
"type": "module",
|
|
29
|
+
"bin": {
|
|
30
|
+
"next-kit": "./dist/check/cli.js"
|
|
31
|
+
},
|
|
28
32
|
"exports": {
|
|
29
33
|
"./server": {
|
|
30
34
|
"types": "./dist/server/index.d.ts",
|
|
@@ -54,6 +58,18 @@
|
|
|
54
58
|
"types": "./dist/seo/index.d.ts",
|
|
55
59
|
"default": "./dist/seo/index.js"
|
|
56
60
|
},
|
|
61
|
+
"./api-keys": {
|
|
62
|
+
"types": "./dist/api-keys/index.d.ts",
|
|
63
|
+
"default": "./dist/api-keys/index.js"
|
|
64
|
+
},
|
|
65
|
+
"./docs": {
|
|
66
|
+
"types": "./dist/docs/index.d.ts",
|
|
67
|
+
"default": "./dist/docs/index.js"
|
|
68
|
+
},
|
|
69
|
+
"./docs/files": {
|
|
70
|
+
"types": "./dist/docs/files/index.d.ts",
|
|
71
|
+
"default": "./dist/docs/files/index.js"
|
|
72
|
+
},
|
|
57
73
|
"./package.json": "./package.json"
|
|
58
74
|
},
|
|
59
75
|
"files": [
|
|
@@ -84,7 +100,7 @@
|
|
|
84
100
|
},
|
|
85
101
|
"peerDependencies": {
|
|
86
102
|
"@haruhimemoe/osu": "^0.2.0 || ^0.3.0 || ^0.4.0",
|
|
87
|
-
"@haruhimemoe/ui": "^0.5.0",
|
|
103
|
+
"@haruhimemoe/ui": "^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
|
88
104
|
"better-auth": "^1.7.5",
|
|
89
105
|
"mongodb": "^7.6.0",
|
|
90
106
|
"mongodb-memory-server": "^11.3.0",
|