@mezzanine-stack/cms-core 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 +26 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +563 -0
- package/coverage/coverage-final.json +4 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/index.html +116 -0
- package/coverage/src/index.ts.html +160 -0
- package/coverage/src/serializer/index.html +131 -0
- package/coverage/src/serializer/json.ts.html +679 -0
- package/coverage/src/serializer/markdown.ts.html +1597 -0
- package/package.json +29 -0
- package/src/index.test.ts +11 -0
- package/src/index.ts +25 -0
- package/src/serializer/json.test.ts +165 -0
- package/src/serializer/json.ts +198 -0
- package/src/serializer/markdown.test.ts +222 -0
- package/src/serializer/markdown.ts +504 -0
- package/src/types.ts +110 -0
- package/tsconfig.json +5 -0
- package/vitest.config.ts +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mezzanine-stack/cms-core",
|
|
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
|
+
"dependencies": {
|
|
17
|
+
"@mezzanine-stack/content-schema": "0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
21
|
+
"typescript": "^5.7.3",
|
|
22
|
+
"vitest": "^3.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:coverage": "vitest run --coverage"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as api from "./index.js";
|
|
3
|
+
|
|
4
|
+
describe("public api exports", () => {
|
|
5
|
+
it("exports serializer functions", () => {
|
|
6
|
+
expect(typeof api.serializeMarkdown).toBe("function");
|
|
7
|
+
expect(typeof api.deserializeMarkdown).toBe("function");
|
|
8
|
+
expect(typeof api.serializeJson).toBe("function");
|
|
9
|
+
expect(typeof api.deserializeJson).toBe("function");
|
|
10
|
+
});
|
|
11
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// cms-core 公開 API
|
|
2
|
+
|
|
3
|
+
// 型定義
|
|
4
|
+
export type {
|
|
5
|
+
FieldValues,
|
|
6
|
+
CatalogEntry,
|
|
7
|
+
EntryModel,
|
|
8
|
+
SaveResult,
|
|
9
|
+
AssetItem,
|
|
10
|
+
GitProvider,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
// Markdown シリアライザ
|
|
14
|
+
export {
|
|
15
|
+
serializeMarkdown,
|
|
16
|
+
deserializeMarkdown,
|
|
17
|
+
type MarkdownParseResult,
|
|
18
|
+
} from "./serializer/markdown.js";
|
|
19
|
+
|
|
20
|
+
// JSON シリアライザ
|
|
21
|
+
export {
|
|
22
|
+
serializeJson,
|
|
23
|
+
deserializeJson,
|
|
24
|
+
type JsonParseResult,
|
|
25
|
+
} from "./serializer/json.js";
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
boolean,
|
|
4
|
+
date,
|
|
5
|
+
file,
|
|
6
|
+
image,
|
|
7
|
+
list,
|
|
8
|
+
object,
|
|
9
|
+
relation,
|
|
10
|
+
richText,
|
|
11
|
+
select,
|
|
12
|
+
text,
|
|
13
|
+
} from "@mezzanine-stack/content-schema";
|
|
14
|
+
import { deserializeJson, serializeJson } from "./json.js";
|
|
15
|
+
|
|
16
|
+
const fields = [
|
|
17
|
+
text({ name: "title", label: "Title", default: "untitled" }),
|
|
18
|
+
richText({ name: "body", label: "Body" }),
|
|
19
|
+
date({ name: "publishedAt", label: "Published" }),
|
|
20
|
+
image({ name: "thumbnail", label: "Thumb" }),
|
|
21
|
+
file({ name: "attachment", label: "Attachment" }),
|
|
22
|
+
relation({ name: "author", label: "Author", collection: "authors", valueField: "id", searchFields: ["name"] }),
|
|
23
|
+
boolean({ name: "draft", label: "Draft", default: false }),
|
|
24
|
+
select({ name: "category", label: "Category", options: ["news", "tech"], default: "news" }),
|
|
25
|
+
select({ name: "tags", label: "Tags", options: ["a", "b"], multiple: true }),
|
|
26
|
+
object({
|
|
27
|
+
name: "meta",
|
|
28
|
+
label: "Meta",
|
|
29
|
+
fields: [
|
|
30
|
+
text({ name: "author", label: "Author", default: "anonymous" }),
|
|
31
|
+
boolean({ name: "featured", label: "Featured", default: true }),
|
|
32
|
+
richText({ name: "notes", label: "Notes" }),
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
list({
|
|
36
|
+
name: "sections",
|
|
37
|
+
label: "Sections",
|
|
38
|
+
fields: [
|
|
39
|
+
text({ name: "heading", label: "Heading", default: "h" }),
|
|
40
|
+
select({ name: "kind", label: "Kind", options: ["text", "image"], default: "text" }),
|
|
41
|
+
boolean({ name: "visible", label: "Visible", default: true }),
|
|
42
|
+
richText({ name: "summary", label: "Summary" }),
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
list({ name: "images", label: "Images", itemType: "image" }),
|
|
46
|
+
{ name: "legacy", label: "Legacy", type: "unknown" } as never,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
describe("serializeJson / deserializeJson", () => {
|
|
50
|
+
it("serializes field values with type normalization", () => {
|
|
51
|
+
const json = serializeJson(
|
|
52
|
+
{
|
|
53
|
+
title: 123,
|
|
54
|
+
body: null,
|
|
55
|
+
publishedAt: "2026-01-01",
|
|
56
|
+
thumbnail: null,
|
|
57
|
+
attachment: "/files/doc.pdf",
|
|
58
|
+
author: 42,
|
|
59
|
+
draft: 0,
|
|
60
|
+
category: null,
|
|
61
|
+
tags: "a",
|
|
62
|
+
meta: "invalid-object",
|
|
63
|
+
sections: [{ heading: "Intro" }, "fallback"],
|
|
64
|
+
images: ["/a.jpg", 99],
|
|
65
|
+
legacy: { keep: true },
|
|
66
|
+
},
|
|
67
|
+
fields,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const parsed = JSON.parse(json) as Record<string, unknown>;
|
|
71
|
+
expect(parsed.title).toBe("123");
|
|
72
|
+
expect(parsed.body).toBe("");
|
|
73
|
+
expect(parsed.thumbnail).toBe("");
|
|
74
|
+
expect(parsed.author).toBe("42");
|
|
75
|
+
expect(parsed.draft).toBe(false);
|
|
76
|
+
expect(parsed.category).toBe("");
|
|
77
|
+
expect(parsed.tags).toEqual([]);
|
|
78
|
+
expect(parsed.meta).toEqual({ author: "anonymous", featured: true, notes: "" });
|
|
79
|
+
expect(parsed.sections).toEqual([
|
|
80
|
+
{ heading: "Intro", kind: "text", visible: true, summary: "" },
|
|
81
|
+
"fallback",
|
|
82
|
+
]);
|
|
83
|
+
expect(parsed.images).toEqual(["/a.jpg", "99"]);
|
|
84
|
+
expect(parsed.legacy).toEqual({ keep: true });
|
|
85
|
+
expect(json.endsWith("\n")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("coerces values from JSON including defaults and fallback branches", () => {
|
|
89
|
+
const parsed = deserializeJson(
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
title: 7,
|
|
92
|
+
body: 8,
|
|
93
|
+
publishedAt: null,
|
|
94
|
+
thumbnail: 5,
|
|
95
|
+
attachment: 4,
|
|
96
|
+
author: 3,
|
|
97
|
+
draft: "",
|
|
98
|
+
category: 123,
|
|
99
|
+
tags: "not-array",
|
|
100
|
+
meta: [],
|
|
101
|
+
sections: [{ heading: 10, visible: "", summary: null }, "plain"],
|
|
102
|
+
images: ["single", 2],
|
|
103
|
+
legacy: "passthrough",
|
|
104
|
+
}),
|
|
105
|
+
fields,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(parsed.values.title).toBe("7");
|
|
109
|
+
expect(parsed.values.body).toBe("8");
|
|
110
|
+
expect(parsed.values.publishedAt).toBe("");
|
|
111
|
+
expect(parsed.values.thumbnail).toBe("5");
|
|
112
|
+
expect(parsed.values.attachment).toBe("4");
|
|
113
|
+
expect(parsed.values.author).toBe("3");
|
|
114
|
+
expect(parsed.values.draft).toBe(false);
|
|
115
|
+
expect(parsed.values.category).toBe("123");
|
|
116
|
+
expect(parsed.values.tags).toEqual([]);
|
|
117
|
+
expect(parsed.values.meta).toEqual({ author: "anonymous", featured: true, notes: "" });
|
|
118
|
+
expect(parsed.values.sections).toEqual([
|
|
119
|
+
{ heading: "10", kind: "text", visible: false, summary: "" },
|
|
120
|
+
"plain",
|
|
121
|
+
]);
|
|
122
|
+
expect(parsed.values.images).toEqual(["single", "2"]);
|
|
123
|
+
expect(parsed.values.legacy).toBe("passthrough");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("fills defaults when values are missing", () => {
|
|
127
|
+
const parsed = deserializeJson("{}", fields);
|
|
128
|
+
expect(parsed.values.title).toBe("untitled");
|
|
129
|
+
expect(parsed.values.body).toBe("");
|
|
130
|
+
expect(parsed.values.draft).toBe(false);
|
|
131
|
+
expect(parsed.values.category).toBe("news");
|
|
132
|
+
expect(parsed.values.tags).toEqual([]);
|
|
133
|
+
expect(parsed.values.meta).toEqual({});
|
|
134
|
+
expect(parsed.values.sections).toEqual([]);
|
|
135
|
+
expect(parsed.values.images).toEqual([]);
|
|
136
|
+
expect(parsed.values.legacy).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("preserves nested object values when object input is valid", () => {
|
|
140
|
+
const json = serializeJson(
|
|
141
|
+
{
|
|
142
|
+
meta: { author: "Jane", featured: false, notes: "ok" },
|
|
143
|
+
},
|
|
144
|
+
fields,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const parsed = JSON.parse(json) as Record<string, unknown>;
|
|
148
|
+
expect(parsed.meta).toEqual({ author: "Jane", featured: false, notes: "ok" });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("coerces object field from valid object input", () => {
|
|
152
|
+
const parsed = deserializeJson(
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
meta: { author: "Jane", featured: true, notes: "ok" },
|
|
155
|
+
}),
|
|
156
|
+
fields,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(parsed.values.meta).toEqual({ author: "Jane", featured: true, notes: "ok" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("throws on invalid JSON", () => {
|
|
163
|
+
expect(() => deserializeJson("{", fields)).toThrow(SyntaxError);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON file のシリアライザ / デシリアライザ。
|
|
3
|
+
* file collection (profile など固定 JSON ファイル) の読み書きに使う。
|
|
4
|
+
*
|
|
5
|
+
* JSON ファイルは frontmatter を持たないため、
|
|
6
|
+
* ファイル全体が JSON オブジェクトとなる。
|
|
7
|
+
* MezzField 定義を使って型強制を行う。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MezzField, MezzListField, MezzObjectField, MezzSelectField } from "@mezzanine-stack/content-schema";
|
|
11
|
+
import type { FieldValues } from "../types.js";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Serialization: FieldValues → JSON string
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* フィールド値を JSON 文字列にシリアライズする。
|
|
19
|
+
* rich-text フィールドも JSON に含める (file collection は body を持たない)。
|
|
20
|
+
*/
|
|
21
|
+
export function serializeJson(values: FieldValues, fields: MezzField[]): string {
|
|
22
|
+
const obj: Record<string, unknown> = {};
|
|
23
|
+
for (const field of fields) {
|
|
24
|
+
const value = values[field.name];
|
|
25
|
+
if (value !== undefined) {
|
|
26
|
+
obj[field.name] = normalizeForJson(field, value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return JSON.stringify(obj, null, 2) + "\n";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeForJson(field: MezzField, value: unknown): unknown {
|
|
33
|
+
switch (field.type) {
|
|
34
|
+
case "text":
|
|
35
|
+
case "rich-text":
|
|
36
|
+
case "date":
|
|
37
|
+
case "image":
|
|
38
|
+
case "file":
|
|
39
|
+
case "relation":
|
|
40
|
+
return value === null || value === undefined ? "" : String(value);
|
|
41
|
+
|
|
42
|
+
case "boolean":
|
|
43
|
+
return Boolean(value);
|
|
44
|
+
|
|
45
|
+
case "select": {
|
|
46
|
+
const sf = field as MezzSelectField;
|
|
47
|
+
if (sf.multiple) {
|
|
48
|
+
return Array.isArray(value) ? value.map(String) : [];
|
|
49
|
+
}
|
|
50
|
+
return value === null || value === undefined ? "" : String(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "list": {
|
|
54
|
+
const lf = field as MezzListField;
|
|
55
|
+
const arr = Array.isArray(value) ? value : [];
|
|
56
|
+
if (lf.fields && lf.fields.length > 0) {
|
|
57
|
+
return arr.map((item) => {
|
|
58
|
+
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
59
|
+
const obj: Record<string, unknown> = {};
|
|
60
|
+
for (const sf of lf.fields!) {
|
|
61
|
+
const sv = (item as Record<string, unknown>)[sf.name];
|
|
62
|
+
obj[sf.name] = sv !== undefined ? normalizeForJson(sf, sv) : defaultValue(sf);
|
|
63
|
+
}
|
|
64
|
+
return obj;
|
|
65
|
+
}
|
|
66
|
+
return String(item);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return arr.map(String);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case "object": {
|
|
73
|
+
const of_ = field as MezzObjectField;
|
|
74
|
+
const obj =
|
|
75
|
+
typeof value === "object" && !Array.isArray(value) && value !== null
|
|
76
|
+
? (value as Record<string, unknown>)
|
|
77
|
+
: {};
|
|
78
|
+
const result: Record<string, unknown> = {};
|
|
79
|
+
for (const sf of of_.fields) {
|
|
80
|
+
const sv = obj[sf.name];
|
|
81
|
+
result[sf.name] = sv !== undefined ? normalizeForJson(sf, sv) : defaultValue(sf);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
default:
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Deserialization: JSON string → FieldValues
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export interface JsonParseResult {
|
|
96
|
+
values: FieldValues;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* JSON 文字列をフィールド値にデシリアライズする。
|
|
101
|
+
* MezzField 定義を使って型変換を行う。
|
|
102
|
+
* @throws {SyntaxError} JSON パースエラー時
|
|
103
|
+
*/
|
|
104
|
+
export function deserializeJson(content: string, fields: MezzField[]): JsonParseResult {
|
|
105
|
+
const raw = JSON.parse(content) as Record<string, unknown>;
|
|
106
|
+
const values: FieldValues = {};
|
|
107
|
+
for (const field of fields) {
|
|
108
|
+
const rawVal = raw[field.name];
|
|
109
|
+
values[field.name] =
|
|
110
|
+
rawVal === undefined || rawVal === null
|
|
111
|
+
? defaultValue(field)
|
|
112
|
+
: coerceField(field, rawVal);
|
|
113
|
+
}
|
|
114
|
+
return { values };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function coerceField(field: MezzField, value: unknown): unknown {
|
|
118
|
+
switch (field.type) {
|
|
119
|
+
case "text":
|
|
120
|
+
case "rich-text":
|
|
121
|
+
case "date":
|
|
122
|
+
case "image":
|
|
123
|
+
case "file":
|
|
124
|
+
case "relation":
|
|
125
|
+
return value === null || value === undefined ? "" : String(value);
|
|
126
|
+
|
|
127
|
+
case "boolean":
|
|
128
|
+
return Boolean(value);
|
|
129
|
+
|
|
130
|
+
case "select": {
|
|
131
|
+
const sf = field as MezzSelectField;
|
|
132
|
+
if (sf.multiple) {
|
|
133
|
+
return Array.isArray(value) ? value.map(String) : [];
|
|
134
|
+
}
|
|
135
|
+
return value === null || value === undefined ? "" : String(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case "list": {
|
|
139
|
+
const lf = field as MezzListField;
|
|
140
|
+
if (!Array.isArray(value)) return [];
|
|
141
|
+
if (lf.fields && lf.fields.length > 0) {
|
|
142
|
+
return value.map((item) => {
|
|
143
|
+
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
144
|
+
const obj: Record<string, unknown> = {};
|
|
145
|
+
for (const sf of lf.fields!) {
|
|
146
|
+
const sv = (item as Record<string, unknown>)[sf.name];
|
|
147
|
+
obj[sf.name] = sv !== undefined ? coerceField(sf, sv) : defaultValue(sf);
|
|
148
|
+
}
|
|
149
|
+
return obj;
|
|
150
|
+
}
|
|
151
|
+
return String(item);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return value.map(String);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case "object": {
|
|
158
|
+
const of_ = field as MezzObjectField;
|
|
159
|
+
const obj =
|
|
160
|
+
typeof value === "object" && !Array.isArray(value) && value !== null
|
|
161
|
+
? (value as Record<string, unknown>)
|
|
162
|
+
: {};
|
|
163
|
+
const result: Record<string, unknown> = {};
|
|
164
|
+
for (const sf of of_.fields) {
|
|
165
|
+
const sv = obj[sf.name];
|
|
166
|
+
result[sf.name] = sv !== undefined ? coerceField(sf, sv) : defaultValue(sf);
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
default:
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function defaultValue(field: MezzField): unknown {
|
|
177
|
+
switch (field.type) {
|
|
178
|
+
case "text":
|
|
179
|
+
case "rich-text":
|
|
180
|
+
case "date":
|
|
181
|
+
case "image":
|
|
182
|
+
case "file":
|
|
183
|
+
case "relation":
|
|
184
|
+
return (field as { default?: string }).default ?? "";
|
|
185
|
+
case "boolean":
|
|
186
|
+
return (field as { default?: boolean }).default ?? false;
|
|
187
|
+
case "select":
|
|
188
|
+
return (field as MezzSelectField).multiple
|
|
189
|
+
? []
|
|
190
|
+
: ((field as { default?: string }).default ?? "");
|
|
191
|
+
case "list":
|
|
192
|
+
return [];
|
|
193
|
+
case "object":
|
|
194
|
+
return {};
|
|
195
|
+
default:
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
boolean,
|
|
4
|
+
date,
|
|
5
|
+
file,
|
|
6
|
+
image,
|
|
7
|
+
list,
|
|
8
|
+
object,
|
|
9
|
+
relation,
|
|
10
|
+
richText,
|
|
11
|
+
select,
|
|
12
|
+
text,
|
|
13
|
+
} from "@mezzanine-stack/content-schema";
|
|
14
|
+
import { deserializeMarkdown, serializeMarkdown } from "./markdown.js";
|
|
15
|
+
|
|
16
|
+
const fields = [
|
|
17
|
+
text({ name: "title", label: "Title", default: "untitled" }),
|
|
18
|
+
date({ name: "publishedAt", label: "Published" }),
|
|
19
|
+
image({ name: "thumbnail", label: "Thumbnail" }),
|
|
20
|
+
file({ name: "attachment", label: "Attachment" }),
|
|
21
|
+
relation({ name: "author", label: "Author", collection: "authors", valueField: "id", searchFields: ["name"] }),
|
|
22
|
+
boolean({ name: "draft", label: "Draft", default: false }),
|
|
23
|
+
select({ name: "category", label: "Category", options: ["news", "tech"], default: "news" }),
|
|
24
|
+
select({ name: "tags", label: "Tags", options: ["a", "b"], multiple: true }),
|
|
25
|
+
list({ name: "images", label: "Images", itemType: "image" }),
|
|
26
|
+
list({
|
|
27
|
+
name: "sections",
|
|
28
|
+
label: "Sections",
|
|
29
|
+
fields: [
|
|
30
|
+
text({ name: "heading", label: "Heading", default: "h" }),
|
|
31
|
+
boolean({ name: "visible", label: "Visible", default: true }),
|
|
32
|
+
richText({ name: "summary", label: "Summary" }),
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
list({
|
|
36
|
+
name: "blocks",
|
|
37
|
+
label: "Blocks",
|
|
38
|
+
fields: [
|
|
39
|
+
select({ name: "flags", label: "Flags", options: ["a", "b"], multiple: true }),
|
|
40
|
+
text({ name: "label", label: "Label", default: "none" }),
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
object({
|
|
44
|
+
name: "meta",
|
|
45
|
+
label: "Meta",
|
|
46
|
+
fields: [
|
|
47
|
+
text({ name: "owner", label: "Owner", default: "anon" }),
|
|
48
|
+
select({ name: "state", label: "State", options: ["open", "closed"], default: "open" }),
|
|
49
|
+
richText({ name: "notes", label: "Notes" }),
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
richText({ name: "body", label: "Body" }),
|
|
53
|
+
{ name: "legacy", label: "Legacy", type: "unknown" } as never,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
describe("serializeMarkdown / deserializeMarkdown", () => {
|
|
57
|
+
it("serializes complex values including quoting and nested blocks", () => {
|
|
58
|
+
const content = serializeMarkdown(
|
|
59
|
+
{
|
|
60
|
+
title: " true ",
|
|
61
|
+
publishedAt: "2026-01-01",
|
|
62
|
+
thumbnail: "/img.jpg",
|
|
63
|
+
attachment: "/file.pdf",
|
|
64
|
+
author: "users/me",
|
|
65
|
+
draft: false,
|
|
66
|
+
category: "tech",
|
|
67
|
+
tags: [],
|
|
68
|
+
images: ["one", "two"],
|
|
69
|
+
sections: [{ heading: "Intro", visible: true }, { summary: "line1\nline2" }],
|
|
70
|
+
blocks: [{ flags: ["a", "b"], label: "hero" }],
|
|
71
|
+
meta: { owner: "#tag", notes: "multi\nline" },
|
|
72
|
+
body: "ignored",
|
|
73
|
+
legacy: "legacy-value",
|
|
74
|
+
},
|
|
75
|
+
"# Heading\n\nBody text.",
|
|
76
|
+
fields,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(content).toContain('title: " true "');
|
|
80
|
+
expect(content).toContain("tags:");
|
|
81
|
+
expect(content).toContain("tags: []");
|
|
82
|
+
expect(content).toContain("images:");
|
|
83
|
+
expect(content).toContain(" - one");
|
|
84
|
+
expect(content).toContain(" - two");
|
|
85
|
+
expect(content).toContain("sections:");
|
|
86
|
+
expect(content).toContain(" - heading: Intro");
|
|
87
|
+
expect(content).toContain("blocks:");
|
|
88
|
+
expect(content).toContain(" - a");
|
|
89
|
+
expect(content).toContain(" - b");
|
|
90
|
+
expect(content).toContain(' owner: "#tag"');
|
|
91
|
+
expect(content).toContain('legacy: legacy-value');
|
|
92
|
+
expect(content).toContain("---\n# Heading");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("serializes empty primitive list as []", () => {
|
|
96
|
+
const content = serializeMarkdown(
|
|
97
|
+
{
|
|
98
|
+
images: [],
|
|
99
|
+
},
|
|
100
|
+
"body",
|
|
101
|
+
fields,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(content).toContain("images: []");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("deserializes edge-case frontmatter structures and coercions", () => {
|
|
108
|
+
const parsed = deserializeMarkdown(
|
|
109
|
+
[
|
|
110
|
+
"---",
|
|
111
|
+
"# comment",
|
|
112
|
+
"invalid-line",
|
|
113
|
+
"title: \"hello\"",
|
|
114
|
+
"publishedAt: ~",
|
|
115
|
+
"thumbnail: /img.png",
|
|
116
|
+
"attachment: /f.pdf",
|
|
117
|
+
"author: users/1",
|
|
118
|
+
"draft: on",
|
|
119
|
+
"category: true",
|
|
120
|
+
"tags: yes",
|
|
121
|
+
"images:",
|
|
122
|
+
"",
|
|
123
|
+
" - one",
|
|
124
|
+
" ignored line",
|
|
125
|
+
" - two",
|
|
126
|
+
"sections:",
|
|
127
|
+
"",
|
|
128
|
+
" - heading: Start",
|
|
129
|
+
" visible: off",
|
|
130
|
+
" summary: ''",
|
|
131
|
+
" -",
|
|
132
|
+
" heading: Next",
|
|
133
|
+
"sectionsScalar:",
|
|
134
|
+
" - just-text",
|
|
135
|
+
"meta:",
|
|
136
|
+
" owner: admin",
|
|
137
|
+
" state: {}",
|
|
138
|
+
"blocks: x",
|
|
139
|
+
"legacy: '{}'",
|
|
140
|
+
"emptyBlock: |",
|
|
141
|
+
"---",
|
|
142
|
+
"body line",
|
|
143
|
+
].join("\n"),
|
|
144
|
+
fields,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(parsed.body).toBe("body line");
|
|
148
|
+
expect(parsed.values.title).toBe("hello");
|
|
149
|
+
expect(parsed.values.publishedAt).toBe("");
|
|
150
|
+
expect(parsed.values.draft).toBe(true);
|
|
151
|
+
expect(parsed.values.category).toBe("true");
|
|
152
|
+
expect(parsed.values.tags).toEqual(["true"]);
|
|
153
|
+
expect(parsed.values.images).toEqual(["one", "two"]);
|
|
154
|
+
expect(parsed.values.sections).toEqual([
|
|
155
|
+
{ heading: "Start", visible: false, summary: "" },
|
|
156
|
+
{ heading: "Next", visible: true, summary: "" },
|
|
157
|
+
]);
|
|
158
|
+
expect(parsed.values.meta).toEqual({ owner: "admin", state: "[object Object]", notes: "" });
|
|
159
|
+
expect(parsed.values.blocks).toEqual([]);
|
|
160
|
+
expect(parsed.values.legacy).toBe("{}");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("supports sequence block objects and scalar parsing variants", () => {
|
|
164
|
+
const parsed = deserializeMarkdown(
|
|
165
|
+
[
|
|
166
|
+
"---",
|
|
167
|
+
"tags:",
|
|
168
|
+
" - a",
|
|
169
|
+
" - b",
|
|
170
|
+
"sections:",
|
|
171
|
+
" -",
|
|
172
|
+
" heading: Item1",
|
|
173
|
+
" visible: yes",
|
|
174
|
+
"",
|
|
175
|
+
" - heading: Item2",
|
|
176
|
+
" visible: no",
|
|
177
|
+
" - plain",
|
|
178
|
+
"meta:",
|
|
179
|
+
" owner: 'single-quoted'",
|
|
180
|
+
" notes: '\"broken'",
|
|
181
|
+
"draft: maybe",
|
|
182
|
+
"---",
|
|
183
|
+
"text",
|
|
184
|
+
].join("\n"),
|
|
185
|
+
fields,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(parsed.values.tags).toEqual(["a", "b"]);
|
|
189
|
+
expect(parsed.values.sections).toEqual([
|
|
190
|
+
{ heading: "Item1", visible: true, summary: "" },
|
|
191
|
+
{ heading: "Item2", visible: false, summary: "" },
|
|
192
|
+
"plain",
|
|
193
|
+
]);
|
|
194
|
+
expect(parsed.values.meta).toEqual({ owner: "single-quoted", state: "open", notes: '"broken' });
|
|
195
|
+
expect(parsed.values.draft).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("coerces object field to empty object when YAML scalar is array literal", () => {
|
|
199
|
+
const parsed = deserializeMarkdown(
|
|
200
|
+
[
|
|
201
|
+
"---",
|
|
202
|
+
"meta: []",
|
|
203
|
+
"---",
|
|
204
|
+
"text",
|
|
205
|
+
].join("\n"),
|
|
206
|
+
fields,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(parsed.values.meta).toEqual({ owner: "anon", state: "open", notes: "" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns body as-is when frontmatter is missing or unclosed", () => {
|
|
213
|
+
const noFrontmatter = deserializeMarkdown("just markdown body", fields);
|
|
214
|
+
expect(noFrontmatter.body).toBe("just markdown body");
|
|
215
|
+
|
|
216
|
+
const unclosed = deserializeMarkdown("---\ntitle: a", fields);
|
|
217
|
+
expect(unclosed.body).toBe("---\ntitle: a");
|
|
218
|
+
expect(unclosed.values.title).toBe("untitled");
|
|
219
|
+
expect(unclosed.values.draft).toBe(false);
|
|
220
|
+
expect(unclosed.values.tags).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|