@haruhimemoe/next-kit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -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
+ }[];