@sorane/core 0.2.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/package.json +42 -0
- package/src/ai-disclosure.ts +181 -0
- package/src/asset-provenance.ts +141 -0
- package/src/associated-media.ts +93 -0
- package/src/blog-pages.ts +175 -0
- package/src/build.ts +1109 -0
- package/src/c2pa-pass.ts +116 -0
- package/src/catalog.ts +61 -0
- package/src/config.ts +255 -0
- package/src/diagrams/compile-d2.ts +70 -0
- package/src/diagrams/compile-graphviz.ts +71 -0
- package/src/diagrams/compile-mermaid.ts +102 -0
- package/src/diagrams/diagram-hash.ts +5 -0
- package/src/diagrams/diagram-meta.ts +74 -0
- package/src/diagrams/emit-diagram-assets.ts +135 -0
- package/src/diagrams/mermaid-head.ts +6 -0
- package/src/diagrams/needs-async-compile.ts +12 -0
- package/src/diagrams/parse-diagram-fence.ts +109 -0
- package/src/diagrams/rehype-diagram-pre.ts +39 -0
- package/src/diagrams/remark-inject-built-figures.ts +32 -0
- package/src/diagrams/render-async.ts +241 -0
- package/src/diagrams/render-body-section.ts +52 -0
- package/src/diagrams/validate-diagram-alt.ts +56 -0
- package/src/docs.ts +257 -0
- package/src/emit-page.ts +87 -0
- package/src/index.ts +49 -0
- package/src/iptc-xmp-pass.ts +94 -0
- package/src/markdown-image-refs.ts +135 -0
- package/src/migrate.ts +60 -0
- package/src/not-found.ts +64 -0
- package/src/og-meta.ts +18 -0
- package/src/render.ts +233 -0
- package/src/site-labels.ts +97 -0
- package/src/site-meta.ts +138 -0
- package/src/ssg.ts +676 -0
- package/src/static-assets.ts +198 -0
- package/src/theme-assets.ts +16 -0
- package/src/validate-heading-structure.ts +51 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, normalize, relative, resolve } from "node:path";
|
|
3
|
+
import { isImageMetadataPath } from "./iptc-xmp-pass.ts";
|
|
4
|
+
|
|
5
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)]\(([^)]+)\)/g;
|
|
6
|
+
|
|
7
|
+
export type InlineImageKind = "static" | "content";
|
|
8
|
+
|
|
9
|
+
export interface MarkdownImageRef {
|
|
10
|
+
readonly markdownPath: string;
|
|
11
|
+
readonly sourceMdRel: string;
|
|
12
|
+
readonly srcAbs: string;
|
|
13
|
+
readonly kind: InlineImageKind;
|
|
14
|
+
/** サイトルートからの公開パス(例: `static/hero.jpg`, `article/fig.png`) */
|
|
15
|
+
readonly publicPath: string;
|
|
16
|
+
readonly outRel: string;
|
|
17
|
+
readonly alt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isExternalImageRef(target: string): boolean {
|
|
21
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function imageTargetPath(raw: string): string {
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
const unquoted = trimmed.replace(/^<|>$/g, "");
|
|
27
|
+
const pathOnly = unquoted.split(/\s+/)[0] ?? "";
|
|
28
|
+
return pathOnly.replace(/\\/g, "/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function publicPathForHtml(outHtmlRel: string, markdownPath: string): string {
|
|
32
|
+
const htmlDir = dirname(outHtmlRel.replace(/\\/g, "/"));
|
|
33
|
+
const joined = htmlDir.length > 0 ? join(htmlDir, markdownPath) : markdownPath;
|
|
34
|
+
return normalize(joined).replace(/\\/g, "/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Markdown 本文からローカル画像参照を抽出する。 */
|
|
38
|
+
export function extractMarkdownImagePaths(markdown: string): Array<{ alt: string; path: string }> {
|
|
39
|
+
const out: Array<{ alt: string; path: string }> = [];
|
|
40
|
+
for (const match of markdown.matchAll(MARKDOWN_IMAGE_RE)) {
|
|
41
|
+
const path = imageTargetPath(match[2] ?? "");
|
|
42
|
+
if (path.length === 0 || isExternalImageRef(path)) continue;
|
|
43
|
+
out.push({ alt: (match[1] ?? "").trim(), path });
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 単一の Markdown 画像参照を解決する(存在しない・非ラスタは null)。 */
|
|
49
|
+
export function resolveMarkdownImageRef(opts: {
|
|
50
|
+
readonly markdownPath: string;
|
|
51
|
+
readonly sourceMdRel: string;
|
|
52
|
+
readonly outHtmlRel: string;
|
|
53
|
+
readonly contentDir: string;
|
|
54
|
+
readonly cwd: string;
|
|
55
|
+
readonly staticDirName: string;
|
|
56
|
+
readonly alt?: string;
|
|
57
|
+
}): MarkdownImageRef | null {
|
|
58
|
+
const markdownPath = opts.markdownPath.replace(/\\/g, "/");
|
|
59
|
+
if (!isImageMetadataPath(markdownPath)) return null;
|
|
60
|
+
|
|
61
|
+
const sourceDir = dirname(opts.sourceMdRel.replace(/\\/g, "/"));
|
|
62
|
+
const contentResolved = normalize(
|
|
63
|
+
resolve(opts.contentDir, sourceDir === "." ? "" : sourceDir, markdownPath),
|
|
64
|
+
);
|
|
65
|
+
const staticRoot = resolve(opts.cwd, opts.staticDirName);
|
|
66
|
+
|
|
67
|
+
if (contentResolved.startsWith(staticRoot) && existsSync(contentResolved)) {
|
|
68
|
+
const staticRel = relative(staticRoot, contentResolved).replace(/\\/g, "/");
|
|
69
|
+
const publicPath = `${opts.staticDirName}/${staticRel}`;
|
|
70
|
+
return {
|
|
71
|
+
markdownPath,
|
|
72
|
+
sourceMdRel: opts.sourceMdRel,
|
|
73
|
+
srcAbs: contentResolved,
|
|
74
|
+
kind: "static",
|
|
75
|
+
publicPath,
|
|
76
|
+
outRel: join(opts.staticDirName, staticRel).replace(/\\/g, "/"),
|
|
77
|
+
alt: opts.alt ?? "",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
contentResolved.startsWith(opts.contentDir) &&
|
|
83
|
+
existsSync(contentResolved) &&
|
|
84
|
+
isImageMetadataPath(contentResolved)
|
|
85
|
+
) {
|
|
86
|
+
const publicPath = publicPathForHtml(opts.outHtmlRel, markdownPath);
|
|
87
|
+
return {
|
|
88
|
+
markdownPath,
|
|
89
|
+
sourceMdRel: opts.sourceMdRel,
|
|
90
|
+
srcAbs: contentResolved,
|
|
91
|
+
kind: "content",
|
|
92
|
+
publicPath,
|
|
93
|
+
outRel: publicPath,
|
|
94
|
+
alt: opts.alt ?? "",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function collectMarkdownImageRefs(opts: {
|
|
102
|
+
readonly body: string;
|
|
103
|
+
readonly sourceMdRel: string;
|
|
104
|
+
readonly outHtmlRel: string;
|
|
105
|
+
readonly contentDir: string;
|
|
106
|
+
readonly cwd: string;
|
|
107
|
+
readonly staticDirName: string;
|
|
108
|
+
}): MarkdownImageRef[] {
|
|
109
|
+
const out: MarkdownImageRef[] = [];
|
|
110
|
+
for (const { alt, path } of extractMarkdownImagePaths(opts.body)) {
|
|
111
|
+
const resolved = resolveMarkdownImageRef({
|
|
112
|
+
markdownPath: path,
|
|
113
|
+
sourceMdRel: opts.sourceMdRel,
|
|
114
|
+
outHtmlRel: opts.outHtmlRel,
|
|
115
|
+
contentDir: opts.contentDir,
|
|
116
|
+
cwd: opts.cwd,
|
|
117
|
+
staticDirName: opts.staticDirName,
|
|
118
|
+
alt,
|
|
119
|
+
});
|
|
120
|
+
if (resolved) out.push(resolved);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `srcAbs` で重複排除したインライン画像一覧。 */
|
|
126
|
+
export function dedupeMarkdownImageRefs(refs: readonly MarkdownImageRef[]): MarkdownImageRef[] {
|
|
127
|
+
const seen = new Set<string>();
|
|
128
|
+
const out: MarkdownImageRef[] = [];
|
|
129
|
+
for (const ref of refs) {
|
|
130
|
+
if (seen.has(ref.srcAbs)) continue;
|
|
131
|
+
seen.add(ref.srcAbs);
|
|
132
|
+
out.push(ref);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
conceptToOkfMarkdown,
|
|
3
|
+
extract,
|
|
4
|
+
normalizeConcept,
|
|
5
|
+
parseYaml,
|
|
6
|
+
} from "@sorane/okf";
|
|
7
|
+
|
|
8
|
+
function slugFromPath(filePath: string): string {
|
|
9
|
+
const base = filePath.replace(/\\/g, "/").split("/").pop() ?? filePath;
|
|
10
|
+
return base.replace(/\.md$/i, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SUPPORTED_BUMP_PROFILES = new Set(["0.1", "0.2"]);
|
|
14
|
+
|
|
15
|
+
export interface MigrateToOkfOptions {
|
|
16
|
+
readonly bumpProfile?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 旧 frontmatter を OKF native markdown に変換する。 */
|
|
20
|
+
export function migrateToOkf(
|
|
21
|
+
source: string,
|
|
22
|
+
filePath: string,
|
|
23
|
+
opts?: MigrateToOkfOptions,
|
|
24
|
+
): string {
|
|
25
|
+
const { frontmatter, body } = extract(source);
|
|
26
|
+
const raw =
|
|
27
|
+
frontmatter !== null
|
|
28
|
+
? ((parseYaml(frontmatter) as Record<string, unknown>) ?? {})
|
|
29
|
+
: {};
|
|
30
|
+
const concept = normalizeConcept(raw, body, slugFromPath(filePath));
|
|
31
|
+
|
|
32
|
+
const bumpedProfile =
|
|
33
|
+
opts?.bumpProfile !== undefined
|
|
34
|
+
? `sorane-okf/${opts.bumpProfile}`
|
|
35
|
+
: undefined;
|
|
36
|
+
const defaultProfile = bumpedProfile ?? concept.profile ?? "sorane-okf/0.1";
|
|
37
|
+
|
|
38
|
+
const migrated: typeof concept = {
|
|
39
|
+
...concept,
|
|
40
|
+
type: concept.type || "article",
|
|
41
|
+
profile: defaultProfile,
|
|
42
|
+
frontmatter: Object.fromEntries(
|
|
43
|
+
Object.entries(concept.frontmatter).filter(
|
|
44
|
+
([k]) => !["layout", "kind", "date", "publishedAt"].includes(k),
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return conceptToOkfMarkdown(migrated);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseBumpProfileArg(argv: readonly string[]): string | undefined {
|
|
53
|
+
const i = argv.indexOf("--bump-profile");
|
|
54
|
+
if (i < 0 || i + 1 >= argv.length) return undefined;
|
|
55
|
+
const version = argv[i + 1]!.trim();
|
|
56
|
+
if (!SUPPORTED_BUMP_PROFILES.has(version)) {
|
|
57
|
+
throw new Error(`unsupported --bump-profile version: ${version} (supported: 0.1, 0.2)`);
|
|
58
|
+
}
|
|
59
|
+
return version;
|
|
60
|
+
}
|
package/src/not-found.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { OkfConcept } from "@sorane/okf";
|
|
2
|
+
import { escapeHtml, stripDuplicateTitleHeading } from "./render.ts";
|
|
3
|
+
import { siteLabels } from "./site-labels.ts";
|
|
4
|
+
|
|
5
|
+
export const NOT_FOUND_SLUG = "404";
|
|
6
|
+
|
|
7
|
+
export function isNotFoundSource(relPath: string): boolean {
|
|
8
|
+
const base = relPath.replace(/\\/g, "/").split("/").pop() ?? relPath;
|
|
9
|
+
return base.replace(/\.md$/i, "") === NOT_FOUND_SLUG;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NotFoundLabels {
|
|
13
|
+
readonly heading: string;
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly messageAlt?: string;
|
|
16
|
+
readonly backToHome: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function notFoundLabels(lang: string): NotFoundLabels {
|
|
20
|
+
if (lang.startsWith("ja")) {
|
|
21
|
+
return {
|
|
22
|
+
heading: "404",
|
|
23
|
+
message: "指定されたページは存在しません。",
|
|
24
|
+
messageAlt: "Page not found.",
|
|
25
|
+
backToHome: "トップページへ",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
heading: "404",
|
|
30
|
+
message: "Page not found.",
|
|
31
|
+
backToHome: "Back to home",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderDefaultNotFoundBody(lang: string, homeHref = "./index.html"): string {
|
|
36
|
+
const labels = notFoundLabels(lang);
|
|
37
|
+
const alt =
|
|
38
|
+
labels.messageAlt !== undefined
|
|
39
|
+
? `<p lang="en">${escapeHtml(labels.messageAlt)}</p>\n`
|
|
40
|
+
: "";
|
|
41
|
+
return (
|
|
42
|
+
`<article class="article-page not-found-page">\n` +
|
|
43
|
+
`<header>\n<h1>${escapeHtml(labels.heading)}</h1>\n</header>\n` +
|
|
44
|
+
`<div class="article-body">\n` +
|
|
45
|
+
`<p>${escapeHtml(labels.message)}</p>\n` +
|
|
46
|
+
`${alt}` +
|
|
47
|
+
`<p><a href="${escapeHtml(homeHref)}">${escapeHtml(labels.backToHome)}</a></p>\n` +
|
|
48
|
+
`</div>\n</article>`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderCustomNotFoundBody(concept: OkfConcept, bodyHtml: string): string {
|
|
53
|
+
const title = concept.title.trim().length > 0 ? concept.title : notFoundLabels("ja").heading;
|
|
54
|
+
return (
|
|
55
|
+
`<article class="article-page not-found-page">\n` +
|
|
56
|
+
`<header>\n<h1>${escapeHtml(title)}</h1>\n</header>\n` +
|
|
57
|
+
`<div class="article-body">\n${bodyHtml}\n</div>\n` +
|
|
58
|
+
`</article>`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function notFoundBodySource(concept: OkfConcept): string {
|
|
63
|
+
return stripDuplicateTitleHeading(concept.body, concept.title);
|
|
64
|
+
}
|
package/src/og-meta.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** site.og_image / frontmatter og_image を絶対 URL に解決する。base_url 無しの相対パスは undefined。 */
|
|
2
|
+
export function resolveOgImageUrl(baseUrl: string, image?: string): string | undefined {
|
|
3
|
+
if (image === undefined || image.trim().length === 0) return undefined;
|
|
4
|
+
const trimmed = image.trim();
|
|
5
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
6
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
7
|
+
if (base.length === 0) return undefined;
|
|
8
|
+
if (trimmed.startsWith("/")) return `${base}${trimmed}`;
|
|
9
|
+
return `${base}/${trimmed}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** BCP 47 lang を Open Graph locale にざっくり変換する。 */
|
|
13
|
+
export function ogLocaleFromLang(lang: string): string {
|
|
14
|
+
const primary = lang.split("-")[0]?.toLowerCase() ?? "ja";
|
|
15
|
+
if (primary === "ja") return "ja_JP";
|
|
16
|
+
if (primary === "en") return "en_US";
|
|
17
|
+
return lang.replace("-", "_");
|
|
18
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { Schema } from "hast-util-sanitize";
|
|
2
|
+
import type { Root as HastRoot } from "hast";
|
|
3
|
+
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
|
4
|
+
import rehypeRaw from "rehype-raw";
|
|
5
|
+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
6
|
+
import { SlugLedger } from "@sorane/search";
|
|
7
|
+
import type { DiagramsConfig } from "./config.ts";
|
|
8
|
+
import { DEFAULT_DIAGRAMS_CONFIG } from "./config.ts";
|
|
9
|
+
import {
|
|
10
|
+
countDiagramsForConfig,
|
|
11
|
+
type DiagramRenderMeta,
|
|
12
|
+
} from "./diagrams/diagram-meta.ts";
|
|
13
|
+
import { remarkDiagramFences } from "./diagrams/parse-diagram-fence.ts";
|
|
14
|
+
import { rehypeDiagramPre } from "./diagrams/rehype-diagram-pre.ts";
|
|
15
|
+
import rehypeStringify from "rehype-stringify";
|
|
16
|
+
import type { Root as MdastRoot } from "mdast";
|
|
17
|
+
import remarkGfm from "remark-gfm";
|
|
18
|
+
import remarkParse from "remark-parse";
|
|
19
|
+
import remarkRehype from "remark-rehype";
|
|
20
|
+
import { unified } from "unified";
|
|
21
|
+
import { visit } from "unist-util-visit";
|
|
22
|
+
|
|
23
|
+
const schemaAttributes = defaultSchema.attributes ?? {};
|
|
24
|
+
|
|
25
|
+
/** はてな移行記事の HTML を許可しつつ script 等は落とす。 */
|
|
26
|
+
export const sanitizeSchema: Schema = {
|
|
27
|
+
...defaultSchema,
|
|
28
|
+
clobberPrefix: "",
|
|
29
|
+
attributes: {
|
|
30
|
+
...schemaAttributes,
|
|
31
|
+
a: [
|
|
32
|
+
...(schemaAttributes.a ?? []).filter(
|
|
33
|
+
(entry) => (typeof entry === "string" ? entry : entry[0]) !== "className",
|
|
34
|
+
),
|
|
35
|
+
"title",
|
|
36
|
+
["className", "data-footnote-backref", "keyword", "okeyword", "heading-anchor"],
|
|
37
|
+
],
|
|
38
|
+
h1: [...(schemaAttributes.h1 ?? []), "id"],
|
|
39
|
+
h2: [...(schemaAttributes.h2 ?? []), "id"],
|
|
40
|
+
h3: [...(schemaAttributes.h3 ?? []), "id"],
|
|
41
|
+
h4: [...(schemaAttributes.h4 ?? []), "id"],
|
|
42
|
+
h5: [...(schemaAttributes.h5 ?? []), "id"],
|
|
43
|
+
h6: [...(schemaAttributes.h6 ?? []), "id"],
|
|
44
|
+
blockquote: [
|
|
45
|
+
...(schemaAttributes.blockquote ?? []),
|
|
46
|
+
["className", "twitter-tweet"],
|
|
47
|
+
"dataLang",
|
|
48
|
+
"dataDnt",
|
|
49
|
+
"dataConversation",
|
|
50
|
+
],
|
|
51
|
+
span: [
|
|
52
|
+
...(schemaAttributes.span ?? []),
|
|
53
|
+
["style", /^font-style:\s*italic;?$/i],
|
|
54
|
+
],
|
|
55
|
+
figure: [
|
|
56
|
+
[
|
|
57
|
+
"className",
|
|
58
|
+
"figure-image",
|
|
59
|
+
"figure-image-fotolife",
|
|
60
|
+
"mceNonEditable",
|
|
61
|
+
"diagram",
|
|
62
|
+
"diagram--d2",
|
|
63
|
+
"diagram--mermaid",
|
|
64
|
+
"diagram--graphviz",
|
|
65
|
+
],
|
|
66
|
+
"role",
|
|
67
|
+
],
|
|
68
|
+
figcaption: [],
|
|
69
|
+
iframe: ["src", "width", "height", "frameBorder", "allowFullScreen"],
|
|
70
|
+
embed: ["src", "type", "width", "height"],
|
|
71
|
+
object: ["width", "height"],
|
|
72
|
+
param: ["name", "value"],
|
|
73
|
+
img: [...(schemaAttributes.img ?? []), "title", "loading", "decoding"],
|
|
74
|
+
pre: [...(schemaAttributes.pre ?? []), "dataSoraneAlt"],
|
|
75
|
+
},
|
|
76
|
+
tagNames: [
|
|
77
|
+
...(defaultSchema.tagNames ?? []),
|
|
78
|
+
"center",
|
|
79
|
+
"embed",
|
|
80
|
+
"figcaption",
|
|
81
|
+
"figure",
|
|
82
|
+
"iframe",
|
|
83
|
+
"object",
|
|
84
|
+
"param",
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface TocEntry {
|
|
89
|
+
readonly depth: number;
|
|
90
|
+
readonly id: string;
|
|
91
|
+
readonly text: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface RenderOptions {
|
|
95
|
+
readonly diagrams?: DiagramsConfig;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RenderedMarkdown {
|
|
99
|
+
readonly html: string;
|
|
100
|
+
readonly outline: readonly TocEntry[];
|
|
101
|
+
readonly diagrams?: DiagramRenderMeta;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type { DiagramRenderMeta };
|
|
105
|
+
|
|
106
|
+
function hastToPlainText(node: { type?: string; value?: string; children?: unknown[] }): string {
|
|
107
|
+
if (node.type === "text" && typeof node.value === "string") return node.value;
|
|
108
|
+
if (!node.children) return "";
|
|
109
|
+
return node.children
|
|
110
|
+
.map((child) => hastToPlainText(child as { type?: string; value?: string; children?: unknown[] }))
|
|
111
|
+
.join("");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rehypeHeadingIds() {
|
|
115
|
+
const ledger = new SlugLedger();
|
|
116
|
+
return (tree: HastRoot) => {
|
|
117
|
+
visit(tree, "element", (node) => {
|
|
118
|
+
const m = /^h([1-6])$/.exec(node.tagName);
|
|
119
|
+
if (!m) return;
|
|
120
|
+
const text = hastToPlainText(node).trim();
|
|
121
|
+
if (!text) return;
|
|
122
|
+
node.properties ??= {};
|
|
123
|
+
node.properties.id = ledger.next(text);
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function rehypeCollectOutline(outline: TocEntry[]) {
|
|
129
|
+
return () => (tree: HastRoot) => {
|
|
130
|
+
visit(tree, "element", (node) => {
|
|
131
|
+
const m = /^h([2-4])$/.exec(node.tagName);
|
|
132
|
+
if (!m) return;
|
|
133
|
+
const id = node.properties?.id;
|
|
134
|
+
if (typeof id !== "string" || id.length === 0) return;
|
|
135
|
+
const text = hastToPlainText(node)
|
|
136
|
+
.replace(/\s*#\s*$/, "")
|
|
137
|
+
.trim();
|
|
138
|
+
if (!text) return;
|
|
139
|
+
outline.push({ depth: Number(m[1]), id, text });
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function markdownPipeline(
|
|
145
|
+
outline: TocEntry[],
|
|
146
|
+
diagrams: DiagramRenderMeta,
|
|
147
|
+
diagramConfig: DiagramsConfig,
|
|
148
|
+
) {
|
|
149
|
+
return unified()
|
|
150
|
+
.use(remarkParse)
|
|
151
|
+
.use(remarkGfm)
|
|
152
|
+
.use(remarkDiagramFences(diagramConfig))
|
|
153
|
+
.use(() => (tree: MdastRoot) => {
|
|
154
|
+
Object.assign(diagrams, countDiagramsForConfig(tree, diagramConfig));
|
|
155
|
+
})
|
|
156
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
157
|
+
.use(rehypeRaw)
|
|
158
|
+
.use(rehypeDiagramPre)
|
|
159
|
+
.use(rehypeHeadingIds)
|
|
160
|
+
.use(rehypeAutolinkHeadings, {
|
|
161
|
+
behavior: "append",
|
|
162
|
+
properties: {
|
|
163
|
+
className: ["heading-anchor"],
|
|
164
|
+
ariaHidden: "true",
|
|
165
|
+
tabIndex: -1,
|
|
166
|
+
},
|
|
167
|
+
content: { type: "text", value: "#" },
|
|
168
|
+
})
|
|
169
|
+
.use(rehypeCollectOutline(outline))
|
|
170
|
+
.use(rehypeSanitize, sanitizeSchema)
|
|
171
|
+
.use(rehypeStringify);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 相対 .md リンクを .html に書き換える。 */
|
|
175
|
+
export function rewriteLinks(markdown: string): string {
|
|
176
|
+
return markdown.replace(
|
|
177
|
+
/\]\(([^)]+)\)/g,
|
|
178
|
+
(full, target: string) => {
|
|
179
|
+
const trimmed = target.trim();
|
|
180
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return full;
|
|
181
|
+
if (trimmed.startsWith("#")) return full;
|
|
182
|
+
const m = trimmed.match(/^([^#]*?)\.md(#.*)?$/i);
|
|
183
|
+
if (!m) return full;
|
|
184
|
+
return `](${m[1]}.html${m[2] ?? ""})`;
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** ヘッダ h1 と重複する先頭 `# title` 行を除く。 */
|
|
190
|
+
export function stripDuplicateTitleHeading(markdown: string, title: string): string {
|
|
191
|
+
const lines = markdown.split(/\r?\n/);
|
|
192
|
+
let i = 0;
|
|
193
|
+
while (i < lines.length && lines[i].trim() === "") i++;
|
|
194
|
+
if (i >= lines.length) return markdown;
|
|
195
|
+
const m = lines[i].match(/^#\s+(.+)$/);
|
|
196
|
+
if (!m || m[1].trim() !== title.trim()) return markdown;
|
|
197
|
+
const rest = [...lines.slice(0, i), ...lines.slice(i + 1)];
|
|
198
|
+
if (rest[i]?.trim() === "") rest.splice(i, 1);
|
|
199
|
+
return rest.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Markdown 本文をサニタイズ済み HTML と見出し outline に変換する。 */
|
|
203
|
+
export function renderMarkdownDocument(
|
|
204
|
+
markdown: string,
|
|
205
|
+
opts?: RenderOptions,
|
|
206
|
+
): RenderedMarkdown {
|
|
207
|
+
const diagramConfig = opts?.diagrams ?? DEFAULT_DIAGRAMS_CONFIG;
|
|
208
|
+
const outline: TocEntry[] = [];
|
|
209
|
+
const diagrams: DiagramRenderMeta = { mermaid: 0, d2: 0, graphviz: 0 };
|
|
210
|
+
const html = markdownPipeline(outline, diagrams, diagramConfig)
|
|
211
|
+
.processSync(rewriteLinks(markdown))
|
|
212
|
+
.toString()
|
|
213
|
+
.replace(/\r\n?/g, "\n")
|
|
214
|
+
.trimEnd();
|
|
215
|
+
return {
|
|
216
|
+
html: html.length > 0 ? `${html}\n` : "",
|
|
217
|
+
outline,
|
|
218
|
+
diagrams,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Markdown 本文をサニタイズ済み HTML に変換する。 */
|
|
223
|
+
export function renderMarkdown(markdown: string, opts?: RenderOptions): string {
|
|
224
|
+
return renderMarkdownDocument(markdown, opts).html;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function escapeHtml(text: string): string {
|
|
228
|
+
return text
|
|
229
|
+
.replace(/&/g, "&")
|
|
230
|
+
.replace(/</g, "<")
|
|
231
|
+
.replace(/>/g, ">")
|
|
232
|
+
.replace(/"/g, """);
|
|
233
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export interface SiteLabels {
|
|
2
|
+
readonly archive: string;
|
|
3
|
+
readonly search: string;
|
|
4
|
+
readonly feed: string;
|
|
5
|
+
readonly profile: string;
|
|
6
|
+
readonly github: string;
|
|
7
|
+
readonly readMore: string;
|
|
8
|
+
readonly pastArticles: string;
|
|
9
|
+
readonly moreArticles: string;
|
|
10
|
+
readonly yearArchive: string;
|
|
11
|
+
readonly updated: string;
|
|
12
|
+
readonly toc: string;
|
|
13
|
+
readonly documentation: string;
|
|
14
|
+
readonly prevPage: string;
|
|
15
|
+
readonly nextPage: string;
|
|
16
|
+
readonly pageNav: string;
|
|
17
|
+
readonly skipToContent: string;
|
|
18
|
+
readonly docsMenu: string;
|
|
19
|
+
readonly aiDisclosureAria: string;
|
|
20
|
+
readonly aiPolicyLink: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const JA: SiteLabels = {
|
|
24
|
+
archive: "アーカイブ",
|
|
25
|
+
search: "検索",
|
|
26
|
+
feed: "フィード",
|
|
27
|
+
profile: "プロフィール",
|
|
28
|
+
github: "GitHub",
|
|
29
|
+
readMore: "続きを読む →",
|
|
30
|
+
pastArticles: "過去の記事",
|
|
31
|
+
moreArticles: "さらに読む →",
|
|
32
|
+
yearArchive: "年別に探す",
|
|
33
|
+
updated: "更",
|
|
34
|
+
toc: "目次",
|
|
35
|
+
documentation: "ドキュメント",
|
|
36
|
+
prevPage: "前へ",
|
|
37
|
+
nextPage: "次へ",
|
|
38
|
+
pageNav: "ページ",
|
|
39
|
+
skipToContent: "本文へスキップ",
|
|
40
|
+
docsMenu: "ドキュメントメニュー",
|
|
41
|
+
aiDisclosureAria: "AI コンテンツ開示",
|
|
42
|
+
aiPolicyLink: "AI 開示ポリシー",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const EN: SiteLabels = {
|
|
46
|
+
archive: "Archive",
|
|
47
|
+
search: "Search",
|
|
48
|
+
feed: "Feed",
|
|
49
|
+
profile: "Profile",
|
|
50
|
+
github: "GitHub",
|
|
51
|
+
readMore: "Read more →",
|
|
52
|
+
pastArticles: "Archive",
|
|
53
|
+
moreArticles: "More articles →",
|
|
54
|
+
yearArchive: "Browse by year",
|
|
55
|
+
updated: "upd",
|
|
56
|
+
toc: "On this page",
|
|
57
|
+
documentation: "Documentation",
|
|
58
|
+
prevPage: "Previous",
|
|
59
|
+
nextPage: "Next",
|
|
60
|
+
pageNav: "Page",
|
|
61
|
+
skipToContent: "Skip to content",
|
|
62
|
+
docsMenu: "Documentation menu",
|
|
63
|
+
aiDisclosureAria: "AI content disclosure",
|
|
64
|
+
aiPolicyLink: "AI disclosure policy",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function siteLabels(lang: string): SiteLabels {
|
|
68
|
+
return lang.startsWith("ja") ? JA : EN;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** サイトヘッダ・フッタ等の固定 UI 文言(フォントサブセット用) */
|
|
72
|
+
export function siteChromeText(lang: string, siteTitle: string, includeSearch = false): string {
|
|
73
|
+
const labels = siteLabels(lang);
|
|
74
|
+
const parts = [
|
|
75
|
+
siteTitle,
|
|
76
|
+
labels.archive,
|
|
77
|
+
labels.feed,
|
|
78
|
+
labels.profile,
|
|
79
|
+
labels.github,
|
|
80
|
+
labels.readMore,
|
|
81
|
+
labels.pastArticles,
|
|
82
|
+
labels.moreArticles,
|
|
83
|
+
labels.yearArchive,
|
|
84
|
+
labels.updated,
|
|
85
|
+
labels.toc,
|
|
86
|
+
labels.documentation,
|
|
87
|
+
labels.prevPage,
|
|
88
|
+
labels.nextPage,
|
|
89
|
+
labels.skipToContent,
|
|
90
|
+
labels.docsMenu,
|
|
91
|
+
labels.aiDisclosureAria,
|
|
92
|
+
labels.aiPolicyLink,
|
|
93
|
+
"サイト",
|
|
94
|
+
];
|
|
95
|
+
if (includeSearch) parts.push(labels.search);
|
|
96
|
+
return parts.join("");
|
|
97
|
+
}
|