@mezzanine-stack/content-document 0.1.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/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @mezzanine-stack/content-document
2
+
3
+ CMS プレビューや描画共通化向けの Document Model 変換ロジック。Markdown と frontmatter から正規化済みドキュメントを生成します。
4
+
5
+ ## 責務
6
+
7
+ - Markdown 本文を `BodyNode` / `InlineNode` へ変換
8
+ - frontmatter + body から `BlogDocument` を生成
9
+ - frontmatter + body から `MarkdownPageDocument` を生成
10
+ - CMS / Astro 共通で扱える `CmsDocument` 型を提供
11
+
12
+ ## 主要 API
13
+
14
+ - `parseMarkdownToBodyNodes(markdown)`
15
+ - `toBlogDocument({ frontmatter, body })`
16
+ - `toMarkdownPageDocument({ frontmatter, body })`
17
+
18
+ ## 公開型
19
+
20
+ - `InlineNode`
21
+ - `BodyNode`
22
+ - `EntrySource`
23
+ - `BlogDocument`
24
+ - `MarkdownPageDocument`
25
+ - `CmsDocument`
26
+
27
+ ## 依存ライブラリ
28
+
29
+ - `unified`
30
+ - `remark-parse`
31
+
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@mezzanine-stack/content-document",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "MIT",
9
+ "type": "module",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./src/index.ts",
13
+ "types": "./src/index.ts"
14
+ }
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.7.3",
18
+ "vitest": "^3.0.0"
19
+ },
20
+ "dependencies": {
21
+ "remark-parse": "^11.0.0",
22
+ "unified": "^11.0.5"
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run"
27
+ }
28
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ parseMarkdownToBodyNodes,
4
+ toBlogDocument,
5
+ toMarkdownPageDocument,
6
+ } from "./index.js";
7
+
8
+ describe("content-document", () => {
9
+ it("parses markdown body nodes", () => {
10
+ const nodes = parseMarkdownToBodyNodes("# Title\n\nHello **world**");
11
+ expect(nodes.length).toBeGreaterThan(0);
12
+ expect(nodes[0]?.type).toBe("heading");
13
+ });
14
+
15
+ it("normalizes blog document from frontmatter + body", () => {
16
+ const doc = toBlogDocument({
17
+ frontmatter: {
18
+ title: "My Post",
19
+ draft: "1",
20
+ tags: ["news"],
21
+ pubDate: "2026-01-01",
22
+ },
23
+ body: "Hello",
24
+ });
25
+
26
+ expect(doc.type).toBe("blog");
27
+ expect(doc.title).toBe("My Post");
28
+ expect(doc.draft).toBe(true);
29
+ expect(doc.tags).toEqual(["news"]);
30
+ expect(doc.pubDate).toBe("2026-01-01");
31
+ });
32
+
33
+ it("normalizes markdown page document", () => {
34
+ const doc = toMarkdownPageDocument({
35
+ frontmatter: { name: "About", description: "desc" },
36
+ body: "body",
37
+ });
38
+
39
+ expect(doc.type).toBe("markdown-page");
40
+ expect(doc.title).toBe("About");
41
+ expect(doc.description).toBe("desc");
42
+ });
43
+ });
44
+
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { InlineNode, BodyNode, EntrySource, BlogDocument, MarkdownPageDocument, CmsDocument } from "./types.js";
2
+ export { parseMarkdownToBodyNodes } from "./markdown/parseMarkdownToBodyNodes.js";
3
+ export { toBlogDocument, toMarkdownPageDocument } from "./normalize.js";
4
+
@@ -0,0 +1,164 @@
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+
4
+ import type { BodyNode, InlineNode } from "../types.js";
5
+
6
+ type MdastNode = any;
7
+
8
+ function isNonEmptyString(v: unknown): v is string {
9
+ return typeof v === "string" && v.trim().length > 0;
10
+ }
11
+
12
+ function clampHeadingLevel(depth: unknown): 1 | 2 | 3 | 4 {
13
+ const n = typeof depth === "number" ? depth : 2;
14
+ if (n <= 1) return 1;
15
+ if (n === 2) return 2;
16
+ if (n === 3) return 3;
17
+ return 4;
18
+ }
19
+
20
+ function plainTextFromInline(inlines: MdastNode[] | undefined): string {
21
+ if (!inlines) return "";
22
+ return inlines
23
+ .map((n) => {
24
+ if (!n) return "";
25
+ if (n.type === "text") return String(n.value ?? "");
26
+ if (n.type === "inlineCode") return String(n.value ?? "");
27
+ if (Array.isArray(n.children)) return plainTextFromInline(n.children);
28
+ return "";
29
+ })
30
+ .join("");
31
+ }
32
+
33
+ function toInlineNodes(nodes: MdastNode[] | undefined): InlineNode[] {
34
+ if (!nodes || nodes.length === 0) return [];
35
+
36
+ const out: InlineNode[] = [];
37
+ for (const n of nodes) {
38
+ if (!n) continue;
39
+ switch (n.type) {
40
+ case "text": {
41
+ out.push({ type: "text", text: String(n.value ?? "") });
42
+ break;
43
+ }
44
+ case "strong": {
45
+ out.push({ type: "strong", children: toInlineNodes(n.children) });
46
+ break;
47
+ }
48
+ case "emphasis": {
49
+ out.push({ type: "em", children: toInlineNodes(n.children) });
50
+ break;
51
+ }
52
+ case "link": {
53
+ out.push({
54
+ type: "link",
55
+ href: String(n.url ?? ""),
56
+ children: toInlineNodes(n.children),
57
+ });
58
+ break;
59
+ }
60
+ case "inlineCode": {
61
+ out.push({ type: "text", text: String(n.value ?? "") });
62
+ break;
63
+ }
64
+ case "break": {
65
+ out.push({ type: "text", text: "\n" });
66
+ break;
67
+ }
68
+ case "image": {
69
+ // inline image は BodyNode.image 側で扱う方針のため、ここでは alt を文字として扱う
70
+ const alt = isNonEmptyString(n.alt) ? String(n.alt) : "image";
71
+ out.push({ type: "text", text: alt });
72
+ break;
73
+ }
74
+ default: {
75
+ if (Array.isArray(n.children)) {
76
+ out.push(...toInlineNodes(n.children));
77
+ }
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function toBodyNode(node: MdastNode): BodyNode | null {
86
+ if (!node) return null;
87
+
88
+ switch (node.type) {
89
+ case "heading": {
90
+ const level = clampHeadingLevel(node.depth);
91
+ const text = plainTextFromInline(node.children);
92
+ return { type: "heading", level, text };
93
+ }
94
+ case "paragraph": {
95
+ return { type: "paragraph", children: toInlineNodes(node.children) };
96
+ }
97
+ case "image": {
98
+ return {
99
+ type: "image",
100
+ src: String(node.url ?? ""),
101
+ alt: isNonEmptyString(node.alt) ? String(node.alt) : undefined,
102
+ };
103
+ }
104
+ case "blockquote": {
105
+ // blockquote は paragraph を inline にフラット化
106
+ const parts: InlineNode[] = [];
107
+ const children: MdastNode[] = Array.isArray(node.children) ? node.children : [];
108
+ let first = true;
109
+ for (const c of children) {
110
+ if (!c) continue;
111
+ if (c.type === "paragraph") {
112
+ if (!first) parts.push({ type: "text", text: "\n" });
113
+ parts.push(...toInlineNodes(c.children));
114
+ first = false;
115
+ }
116
+ }
117
+ if (parts.length === 0) {
118
+ return { type: "quote", children: [{ type: "text", text: "" }] };
119
+ }
120
+ return { type: "quote", children: parts };
121
+ }
122
+ case "code": {
123
+ return {
124
+ type: "code",
125
+ code: String(node.value ?? ""),
126
+ language: isNonEmptyString(node.lang) ? String(node.lang) : undefined,
127
+ };
128
+ }
129
+ case "list": {
130
+ const ordered = Boolean(node.ordered);
131
+ const items: InlineNode[][] = [];
132
+ const listItems: MdastNode[] = Array.isArray(node.children) ? node.children : [];
133
+ for (const li of listItems) {
134
+ if (!li || li.type !== "listItem") continue;
135
+ const inline: InlineNode[] = [];
136
+ const liChildren: MdastNode[] = Array.isArray(li.children) ? li.children : [];
137
+ for (const lc of liChildren) {
138
+ if (!lc) continue;
139
+ if (lc.type === "paragraph") {
140
+ inline.push(...toInlineNodes(lc.children));
141
+ break; // MVP: 先頭 paragraph のみ
142
+ }
143
+ }
144
+ items.push(inline);
145
+ }
146
+ return { type: "list", ordered, items };
147
+ }
148
+ default:
149
+ return null;
150
+ }
151
+ }
152
+
153
+ export function parseMarkdownToBodyNodes(md: string): BodyNode[] {
154
+ const tree = unified().use(remarkParse).parse(md ?? "") as MdastNode;
155
+ const children: MdastNode[] = Array.isArray(tree.children) ? tree.children : [];
156
+
157
+ const out: BodyNode[] = [];
158
+ for (const n of children) {
159
+ const node = toBodyNode(n);
160
+ if (node) out.push(node);
161
+ }
162
+ return out;
163
+ }
164
+
@@ -0,0 +1,63 @@
1
+ import type { BlogDocument, EntrySource, MarkdownPageDocument } from "./types.js";
2
+ import { parseMarkdownToBodyNodes } from "./markdown/parseMarkdownToBodyNodes.js";
3
+
4
+ function isNonEmptyString(v: unknown): v is string {
5
+ return typeof v === "string" && v.trim().length > 0;
6
+ }
7
+
8
+ function asString(v: unknown): string | undefined {
9
+ return isNonEmptyString(v) ? v : undefined;
10
+ }
11
+
12
+ function asBoolean(v: unknown): boolean {
13
+ if (typeof v === "boolean") return v;
14
+ if (typeof v === "string") return v === "true" || v === "1";
15
+ return Boolean(v);
16
+ }
17
+
18
+ function asStringArray(v: unknown): string[] {
19
+ if (Array.isArray(v)) return v.map((x) => String(x)).filter((s) => s.trim().length > 0);
20
+ return [];
21
+ }
22
+
23
+ function asDateString(v: unknown): string | undefined {
24
+ if (v instanceof Date && !Number.isNaN(v.getTime())) return v.toISOString();
25
+ if (typeof v === "string" && v.trim().length > 0) return v;
26
+ return undefined;
27
+ }
28
+
29
+ export function toBlogDocument(entry: EntrySource): BlogDocument {
30
+ const fm = entry.frontmatter ?? {};
31
+
32
+ const title = asString(fm.title) ?? asString(fm.name) ?? "";
33
+ const description = asString(fm.description);
34
+ const heroSrc = asString(fm.heroImage);
35
+
36
+ return {
37
+ type: "blog",
38
+ template: "blog-standard",
39
+ title,
40
+ description,
41
+ heroImage: heroSrc ? { src: heroSrc, alt: undefined } : undefined,
42
+ pubDate: asDateString(fm.pubDate),
43
+ tags: asStringArray(fm.tags),
44
+ draft: asBoolean(fm.draft),
45
+ body: parseMarkdownToBodyNodes(entry.body ?? ""),
46
+ };
47
+ }
48
+
49
+ export function toMarkdownPageDocument(entry: EntrySource): MarkdownPageDocument {
50
+ const fm = entry.frontmatter ?? {};
51
+
52
+ const title = asString(fm.title) ?? asString(fm.name) ?? "";
53
+ const description = asString(fm.description);
54
+
55
+ return {
56
+ type: "markdown-page",
57
+ template: "markdown-page-standard",
58
+ title,
59
+ description,
60
+ body: parseMarkdownToBodyNodes(entry.body ?? ""),
61
+ };
62
+ }
63
+
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ export type InlineNode =
2
+ | { type: "text"; text: string }
3
+ | { type: "strong"; children: InlineNode[] }
4
+ | { type: "em"; children: InlineNode[] }
5
+ | { type: "link"; href: string; children: InlineNode[] };
6
+
7
+ export type BodyNode =
8
+ | { type: "heading"; level: 1 | 2 | 3 | 4; text: string }
9
+ | { type: "paragraph"; children: InlineNode[] }
10
+ | { type: "image"; src: string; alt?: string; caption?: string }
11
+ | { type: "quote"; children: InlineNode[]; cite?: string }
12
+ | { type: "code"; code: string; language?: string }
13
+ | { type: "list"; ordered: boolean; items: InlineNode[][] };
14
+
15
+ export type EntrySource = {
16
+ frontmatter: Record<string, unknown>;
17
+ body: string;
18
+ };
19
+
20
+ export type BlogDocument = {
21
+ type: "blog";
22
+ template: "blog-standard";
23
+ title: string;
24
+ description?: string;
25
+ heroImage?: { src: string; alt?: string };
26
+ pubDate?: string;
27
+ tags: string[];
28
+ draft: boolean;
29
+ body: BodyNode[];
30
+ };
31
+
32
+ export type MarkdownPageDocument = {
33
+ type: "markdown-page";
34
+ template: "markdown-page-standard";
35
+ title: string;
36
+ description?: string;
37
+ body: BodyNode[];
38
+ };
39
+
40
+ export type CmsDocument = BlogDocument | MarkdownPageDocument;
41
+
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": { "outDir": "dist", "rootDir": "src" },
4
+ "include": ["src"]
5
+ }
6
+
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/**/*.test.ts"],
6
+ },
7
+ });
8
+