@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.
- package/CHANGELOG.md +14 -1
- package/README.md +105 -4
- package/dist/check/cli.d.ts +5 -5
- package/dist/check/cli.js +11 -9
- package/dist/check/standards.d.ts +15 -8
- package/dist/check/standards.js +66 -32
- 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/package.json +10 -2
|
@@ -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
|
+
}[];
|