@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,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 Mon Sep 28, 2026
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 build time
29
- * @returns {string} the security.txt body: Contact, Expires, Preferred-Languages, Canonical,
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 Mon Sep 28, 2026
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 build time
18
- * @returns {string} the security.txt body: Contact, Expires, Preferred-Languages, Canonical,
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.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, and SEO: metadata, robots.txt, sitemaps, JSON-LD and llms.txt.",
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",