@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
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown (frontmatter + body) のシリアライザ / デシリアライザ。
|
|
3
|
+
*
|
|
4
|
+
* 対応フォーマット:
|
|
5
|
+
* ---
|
|
6
|
+
* key: value
|
|
7
|
+
* ---
|
|
8
|
+
* Markdown 本文
|
|
9
|
+
*
|
|
10
|
+
* MezzField 定義を手がかりとして型付き変換を行い、
|
|
11
|
+
* 外部 YAML ライブラリ不要でラウンドトリップを保証する。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
MezzField,
|
|
16
|
+
MezzListField,
|
|
17
|
+
MezzObjectField,
|
|
18
|
+
MezzSelectField,
|
|
19
|
+
} from "@mezzanine-stack/content-schema";
|
|
20
|
+
import type { FieldValues } from "../types.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Serialization: EntryModel → Markdown string
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* フィールド値と本文テキストを Markdown 文字列にシリアライズする。
|
|
28
|
+
* rich-text フィールドは body に入るため frontmatter からは除外する。
|
|
29
|
+
*/
|
|
30
|
+
export function serializeMarkdown(
|
|
31
|
+
values: FieldValues,
|
|
32
|
+
body: string,
|
|
33
|
+
fields: MezzField[]
|
|
34
|
+
): string {
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (const field of fields) {
|
|
38
|
+
if (field.type === "rich-text") continue;
|
|
39
|
+
const value = values[field.name];
|
|
40
|
+
if (value === undefined) continue;
|
|
41
|
+
appendField(field, value, lines, 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const frontmatter = lines.join("\n");
|
|
45
|
+
const normalizedBody = body.endsWith("\n") ? body : `${body}\n`;
|
|
46
|
+
return `---\n${frontmatter}\n---\n${normalizedBody}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appendField(
|
|
50
|
+
field: MezzField,
|
|
51
|
+
value: unknown,
|
|
52
|
+
lines: string[],
|
|
53
|
+
indent: number
|
|
54
|
+
): void {
|
|
55
|
+
const pad = " ".repeat(indent);
|
|
56
|
+
const key = `${pad}${field.name}`;
|
|
57
|
+
|
|
58
|
+
switch (field.type) {
|
|
59
|
+
case "text":
|
|
60
|
+
case "date":
|
|
61
|
+
case "image":
|
|
62
|
+
case "file":
|
|
63
|
+
case "relation":
|
|
64
|
+
lines.push(`${key}: ${yamlString(value == null ? "" : String(value))}`);
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
case "boolean":
|
|
68
|
+
lines.push(`${key}: ${value ? "true" : "false"}`);
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case "select": {
|
|
72
|
+
const sf = field as MezzSelectField;
|
|
73
|
+
if (sf.multiple) {
|
|
74
|
+
const arr = Array.isArray(value) ? value : [];
|
|
75
|
+
if (arr.length === 0) {
|
|
76
|
+
lines.push(`${key}: []`);
|
|
77
|
+
} else {
|
|
78
|
+
lines.push(`${key}:`);
|
|
79
|
+
for (const item of arr) {
|
|
80
|
+
lines.push(`${pad} - ${yamlString(String(item))}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
lines.push(`${key}: ${yamlString(String(value ?? ""))}`);
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case "list": {
|
|
90
|
+
const lf = field as MezzListField;
|
|
91
|
+
const arr = Array.isArray(value) ? value : [];
|
|
92
|
+
if (arr.length === 0) {
|
|
93
|
+
lines.push(`${key}: []`);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (lf.fields && lf.fields.length > 0) {
|
|
97
|
+
lines.push(`${key}:`);
|
|
98
|
+
for (const item of arr) {
|
|
99
|
+
const obj = item as Record<string, unknown>;
|
|
100
|
+
let isFirst = true;
|
|
101
|
+
for (const subField of lf.fields) {
|
|
102
|
+
const sv = obj[subField.name];
|
|
103
|
+
if (sv === undefined) continue;
|
|
104
|
+
const subLines: string[] = [];
|
|
105
|
+
appendField(subField, sv, subLines, 0);
|
|
106
|
+
if (isFirst) {
|
|
107
|
+
lines.push(`${pad} - ${subLines[0].trimStart()}`);
|
|
108
|
+
for (const sl of subLines.slice(1)) {
|
|
109
|
+
lines.push(`${pad} ${sl.trimStart()}`);
|
|
110
|
+
}
|
|
111
|
+
isFirst = false;
|
|
112
|
+
} else {
|
|
113
|
+
for (const sl of subLines) {
|
|
114
|
+
lines.push(`${pad} ${sl.trimStart()}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(`${key}:`);
|
|
121
|
+
for (const item of arr) {
|
|
122
|
+
lines.push(`${pad} - ${yamlString(String(item))}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "object": {
|
|
129
|
+
const of_ = field as MezzObjectField;
|
|
130
|
+
const obj = (typeof value === "object" && value !== null ? value : {}) as Record<string, unknown>;
|
|
131
|
+
lines.push(`${key}:`);
|
|
132
|
+
for (const subField of of_.fields) {
|
|
133
|
+
const sv = obj[subField.name];
|
|
134
|
+
if (sv !== undefined) {
|
|
135
|
+
appendField(subField, sv, lines, indent + 2);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
default:
|
|
142
|
+
lines.push(`${key}: ${yamlString(String(value ?? ""))}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 必要に応じてダブルクォートで囲む */
|
|
147
|
+
function yamlString(value: string): string {
|
|
148
|
+
if (value === "") return '""';
|
|
149
|
+
// YAML の特殊文字・予約語・マルチラインを含む場合はクォート
|
|
150
|
+
const needsQuote =
|
|
151
|
+
/[:#\[\]{},|>&*!%@`]/.test(value) ||
|
|
152
|
+
/^[\s]|[\s]$/.test(value) ||
|
|
153
|
+
/^(true|false|null|~|yes|no|on|off)$/i.test(value) ||
|
|
154
|
+
value.includes("\n") ||
|
|
155
|
+
value.startsWith('"') ||
|
|
156
|
+
value.startsWith("'");
|
|
157
|
+
return needsQuote ? JSON.stringify(value) : value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Deserialization: Markdown string → { values, body }
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export interface MarkdownParseResult {
|
|
165
|
+
values: FieldValues;
|
|
166
|
+
body: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Markdown 文字列をフィールド値と本文テキストにデシリアライズする。
|
|
171
|
+
* MezzField 定義を使って型変換を行う。
|
|
172
|
+
*/
|
|
173
|
+
export function deserializeMarkdown(
|
|
174
|
+
content: string,
|
|
175
|
+
fields: MezzField[]
|
|
176
|
+
): MarkdownParseResult {
|
|
177
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
178
|
+
const raw = parseFrontmatter(frontmatter);
|
|
179
|
+
const values = coerceValues(raw, fields);
|
|
180
|
+
return { values, body };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// frontmatter 分割
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function splitFrontmatter(content: string): {
|
|
188
|
+
frontmatter: string;
|
|
189
|
+
body: string;
|
|
190
|
+
} {
|
|
191
|
+
const lines = content.split("\n");
|
|
192
|
+
if (lines[0]?.trim() !== "---") {
|
|
193
|
+
return { frontmatter: "", body: content };
|
|
194
|
+
}
|
|
195
|
+
let endIdx = -1;
|
|
196
|
+
for (let i = 1; i < lines.length; i++) {
|
|
197
|
+
if (lines[i]?.trim() === "---") {
|
|
198
|
+
endIdx = i;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (endIdx === -1) {
|
|
203
|
+
return { frontmatter: "", body: content };
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
frontmatter: lines.slice(1, endIdx).join("\n"),
|
|
207
|
+
body: lines.slice(endIdx + 1).join("\n"),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// シンプル YAML パーサ (serializeMarkdown が出力するサブセット専用)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
type YamlPrimitive = string | boolean | null;
|
|
216
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
217
|
+
interface YamlObject extends Record<string, YamlValue> {}
|
|
218
|
+
type YamlValue = YamlPrimitive | YamlValue[] | YamlObject;
|
|
219
|
+
|
|
220
|
+
function parseFrontmatter(yaml: string): Record<string, YamlValue> {
|
|
221
|
+
const lines = yaml.split("\n");
|
|
222
|
+
return parseMapping(lines, 0).result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface MappingResult {
|
|
226
|
+
result: Record<string, YamlValue>;
|
|
227
|
+
/** 消費した行数 */
|
|
228
|
+
consumed: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseMapping(lines: string[], startIdx: number): MappingResult {
|
|
232
|
+
const result: Record<string, YamlValue> = {};
|
|
233
|
+
let i = startIdx;
|
|
234
|
+
const baseIndent = getIndent(lines[startIdx] ?? "");
|
|
235
|
+
|
|
236
|
+
while (i < lines.length) {
|
|
237
|
+
const line = lines[i];
|
|
238
|
+
if (line === undefined) break;
|
|
239
|
+
const trimmed = line.trim();
|
|
240
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
241
|
+
i++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const indent = getIndent(line);
|
|
245
|
+
if (indent < baseIndent && i > startIdx) break;
|
|
246
|
+
|
|
247
|
+
const colonIdx = trimmed.indexOf(":");
|
|
248
|
+
if (colonIdx === -1) {
|
|
249
|
+
i++;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
253
|
+
const rest = trimmed.slice(colonIdx + 1).trim();
|
|
254
|
+
|
|
255
|
+
if (rest === "" || rest === "|" || rest === ">") {
|
|
256
|
+
const { value, consumed } = parseBlock(lines, i + 1, indent);
|
|
257
|
+
result[key] = value;
|
|
258
|
+
i += consumed + 1;
|
|
259
|
+
} else {
|
|
260
|
+
result[key] = parseScalar(rest);
|
|
261
|
+
i++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { result, consumed: i - startIdx };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface BlockResult {
|
|
269
|
+
value: YamlValue;
|
|
270
|
+
consumed: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseBlock(
|
|
274
|
+
lines: string[],
|
|
275
|
+
startIdx: number,
|
|
276
|
+
parentIndent: number
|
|
277
|
+
): BlockResult {
|
|
278
|
+
let i = startIdx;
|
|
279
|
+
let blockIndent = -1;
|
|
280
|
+
const blockLines: string[] = [];
|
|
281
|
+
|
|
282
|
+
while (i < lines.length) {
|
|
283
|
+
const line = lines[i];
|
|
284
|
+
if (line === undefined) break;
|
|
285
|
+
if (line.trim() === "") {
|
|
286
|
+
i++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const indent = getIndent(line);
|
|
290
|
+
if (indent <= parentIndent) break;
|
|
291
|
+
if (blockIndent === -1) blockIndent = indent;
|
|
292
|
+
if (indent < blockIndent) break;
|
|
293
|
+
blockLines.push(line);
|
|
294
|
+
i++;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const consumed = i - startIdx;
|
|
298
|
+
if (blockLines.length === 0) return { value: null, consumed };
|
|
299
|
+
|
|
300
|
+
const firstTrimmed = blockLines[0]?.trim() ?? "";
|
|
301
|
+
if (firstTrimmed.startsWith("- ") || firstTrimmed === "-") {
|
|
302
|
+
return { value: parseSequence(blockLines, blockIndent), consumed };
|
|
303
|
+
}
|
|
304
|
+
return { value: parseMapping(blockLines, 0).result, consumed };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseSequence(lines: string[], baseIndent: number): YamlValue[] {
|
|
308
|
+
const result: YamlValue[] = [];
|
|
309
|
+
let i = 0;
|
|
310
|
+
|
|
311
|
+
while (i < lines.length) {
|
|
312
|
+
const line = lines[i];
|
|
313
|
+
if (!line) break;
|
|
314
|
+
const indent = getIndent(line);
|
|
315
|
+
if (indent < baseIndent) break;
|
|
316
|
+
|
|
317
|
+
const trimmed = line.trim();
|
|
318
|
+
if (!trimmed.startsWith("- ") && trimmed !== "-") {
|
|
319
|
+
i++;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const itemStr = trimmed.startsWith("- ") ? trimmed.slice(2).trim() : "";
|
|
324
|
+
|
|
325
|
+
if (itemStr === "") {
|
|
326
|
+
// ブロック形式の object アイテム
|
|
327
|
+
const subLines: string[] = [];
|
|
328
|
+
let j = i + 1;
|
|
329
|
+
while (j < lines.length) {
|
|
330
|
+
const sl = lines[j];
|
|
331
|
+
if (!sl) { j++; continue; }
|
|
332
|
+
if (getIndent(sl) <= indent) break;
|
|
333
|
+
subLines.push(sl);
|
|
334
|
+
j++;
|
|
335
|
+
}
|
|
336
|
+
result.push(subLines.length > 0 ? parseMapping(subLines, 0).result : null);
|
|
337
|
+
i = j;
|
|
338
|
+
} else {
|
|
339
|
+
// インライン: `- key: value` 形式かスカラか
|
|
340
|
+
const colonIdx = itemStr.indexOf(":");
|
|
341
|
+
if (colonIdx !== -1) {
|
|
342
|
+
const firstKey = itemStr.slice(0, colonIdx).trim();
|
|
343
|
+
const firstVal = parseScalar(itemStr.slice(colonIdx + 1).trim());
|
|
344
|
+
const obj: Record<string, YamlValue> = { [firstKey]: firstVal };
|
|
345
|
+
// 追加プロパティを収集
|
|
346
|
+
let j = i + 1;
|
|
347
|
+
while (j < lines.length) {
|
|
348
|
+
const sl = lines[j];
|
|
349
|
+
if (!sl || sl.trim() === "") { j++; continue; }
|
|
350
|
+
const slIndent = getIndent(sl);
|
|
351
|
+
if (slIndent <= indent) break;
|
|
352
|
+
if (sl.trim().startsWith("- ")) break;
|
|
353
|
+
const slColon = sl.indexOf(":");
|
|
354
|
+
if (slColon !== -1) {
|
|
355
|
+
const k = sl.slice(0, slColon).trim();
|
|
356
|
+
const v = parseScalar(sl.slice(slColon + 1).trim());
|
|
357
|
+
obj[k] = v;
|
|
358
|
+
}
|
|
359
|
+
j++;
|
|
360
|
+
}
|
|
361
|
+
result.push(obj);
|
|
362
|
+
i = j;
|
|
363
|
+
} else {
|
|
364
|
+
result.push(parseScalar(itemStr));
|
|
365
|
+
i++;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseScalar(value: string): YamlValue {
|
|
374
|
+
if (value === "null" || value === "~" || value === "") return null;
|
|
375
|
+
if (value === "true" || value === "yes" || value === "on") return true;
|
|
376
|
+
if (value === "false" || value === "no" || value === "off") return false;
|
|
377
|
+
if (value === "[]") return [];
|
|
378
|
+
if (value === "{}") return {};
|
|
379
|
+
if (
|
|
380
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
381
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
382
|
+
) {
|
|
383
|
+
try {
|
|
384
|
+
return JSON.parse(value);
|
|
385
|
+
} catch {
|
|
386
|
+
return value.slice(1, -1);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function getIndent(line: string): number {
|
|
393
|
+
return line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// 型強制: raw YAML → FieldValues
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
function coerceValues(
|
|
401
|
+
raw: Record<string, YamlValue>,
|
|
402
|
+
fields: MezzField[]
|
|
403
|
+
): FieldValues {
|
|
404
|
+
const result: FieldValues = {};
|
|
405
|
+
for (const field of fields) {
|
|
406
|
+
if (field.type === "rich-text") continue;
|
|
407
|
+
const rawVal = raw[field.name];
|
|
408
|
+
result[field.name] =
|
|
409
|
+
rawVal === undefined || rawVal === null
|
|
410
|
+
? defaultValue(field)
|
|
411
|
+
: coerceField(field, rawVal);
|
|
412
|
+
}
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function coerceField(field: MezzField, value: YamlValue): unknown {
|
|
417
|
+
switch (field.type) {
|
|
418
|
+
case "text":
|
|
419
|
+
case "date":
|
|
420
|
+
case "image":
|
|
421
|
+
case "file":
|
|
422
|
+
case "relation":
|
|
423
|
+
return value === null ? "" : String(value);
|
|
424
|
+
|
|
425
|
+
case "boolean":
|
|
426
|
+
return typeof value === "boolean"
|
|
427
|
+
? value
|
|
428
|
+
: value === "true" || value === "yes" || value === "on";
|
|
429
|
+
|
|
430
|
+
case "select": {
|
|
431
|
+
const sf = field as MezzSelectField;
|
|
432
|
+
if (sf.multiple) {
|
|
433
|
+
if (Array.isArray(value)) return value.map(String);
|
|
434
|
+
return value === null ? [] : [String(value)];
|
|
435
|
+
}
|
|
436
|
+
return value === null ? "" : String(value);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
case "list": {
|
|
440
|
+
if (!Array.isArray(value)) return [];
|
|
441
|
+
const lf = field as MezzListField;
|
|
442
|
+
if (lf.fields && lf.fields.length > 0) {
|
|
443
|
+
return value.map((item) => {
|
|
444
|
+
if (
|
|
445
|
+
typeof item === "object" &&
|
|
446
|
+
!Array.isArray(item) &&
|
|
447
|
+
item !== null
|
|
448
|
+
) {
|
|
449
|
+
const obj: Record<string, unknown> = {};
|
|
450
|
+
for (const sf of lf.fields!) {
|
|
451
|
+
const sv = (item as Record<string, YamlValue>)[sf.name];
|
|
452
|
+
obj[sf.name] = sv !== undefined ? coerceField(sf, sv) : defaultValue(sf);
|
|
453
|
+
}
|
|
454
|
+
return obj;
|
|
455
|
+
}
|
|
456
|
+
return String(item);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return value.map(String);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case "object": {
|
|
463
|
+
const of_ = field as MezzObjectField;
|
|
464
|
+
const obj =
|
|
465
|
+
typeof value === "object" && !Array.isArray(value) && value !== null
|
|
466
|
+
? (value as Record<string, YamlValue>)
|
|
467
|
+
: {};
|
|
468
|
+
const result: Record<string, unknown> = {};
|
|
469
|
+
for (const sf of of_.fields) {
|
|
470
|
+
const sv = obj[sf.name];
|
|
471
|
+
result[sf.name] = sv !== undefined ? coerceField(sf, sv) : defaultValue(sf);
|
|
472
|
+
}
|
|
473
|
+
return result;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
default:
|
|
477
|
+
return value;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function defaultValue(field: MezzField): unknown {
|
|
482
|
+
switch (field.type) {
|
|
483
|
+
case "text":
|
|
484
|
+
case "date":
|
|
485
|
+
case "image":
|
|
486
|
+
case "file":
|
|
487
|
+
case "relation":
|
|
488
|
+
return (field as { default?: string }).default ?? "";
|
|
489
|
+
case "boolean":
|
|
490
|
+
return (field as { default?: boolean }).default ?? false;
|
|
491
|
+
case "select":
|
|
492
|
+
return (field as MezzSelectField).multiple
|
|
493
|
+
? []
|
|
494
|
+
: ((field as { default?: string }).default ?? "");
|
|
495
|
+
case "list":
|
|
496
|
+
return [];
|
|
497
|
+
case "object":
|
|
498
|
+
return {};
|
|
499
|
+
case "rich-text":
|
|
500
|
+
return "";
|
|
501
|
+
default:
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// cms-core の公開型定義
|
|
2
|
+
|
|
3
|
+
import type { MezzCollection } from "@mezzanine-stack/content-schema";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 共通値型
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export type FieldValues = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// GitProvider contract
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** コレクション一覧画面で各エントリを表す型 */
|
|
16
|
+
export interface CatalogEntry {
|
|
17
|
+
/** folder collection: slug、file collection: file 名 */
|
|
18
|
+
entryId: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
draft?: boolean;
|
|
21
|
+
updatedAt?: string;
|
|
22
|
+
/** GitHub 上のファイルパス (例: src/content/blog/hello.md) */
|
|
23
|
+
sourcePath: string;
|
|
24
|
+
/** GitHub blob SHA — 保存時の optimistic concurrency に使う */
|
|
25
|
+
revision: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** エントリ編集画面で使う完全なモデル */
|
|
29
|
+
export interface EntryModel {
|
|
30
|
+
collection: MezzCollection;
|
|
31
|
+
entryId: string;
|
|
32
|
+
sourcePath: string;
|
|
33
|
+
/** フォーム用の正規化済み値 */
|
|
34
|
+
values: FieldValues;
|
|
35
|
+
/** Markdown 本文 (rich-text フィールドが存在する場合のみ) */
|
|
36
|
+
body?: string;
|
|
37
|
+
revision: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** saveEntry の戻り値 */
|
|
41
|
+
export interface SaveResult {
|
|
42
|
+
/** 保存後の新しい GitHub blob SHA */
|
|
43
|
+
revision: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** アセット(画像・ファイル)の一覧エントリ */
|
|
47
|
+
export interface AssetItem {
|
|
48
|
+
/** ファイル名 (例: "photo.jpg") */
|
|
49
|
+
name: string;
|
|
50
|
+
/** リポジトリ内パス (例: "public/uploads/photo.jpg") */
|
|
51
|
+
path: string;
|
|
52
|
+
/** Web 上の公開パス (例: "/uploads/photo.jpg") */
|
|
53
|
+
publicPath: string;
|
|
54
|
+
/** ファイルサイズ (bytes) */
|
|
55
|
+
size: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Git プロバイダ非依存のストレージ契約 */
|
|
59
|
+
export interface GitProvider {
|
|
60
|
+
/**
|
|
61
|
+
* 指定ディレクトリ配下のエントリ一覧を返す。
|
|
62
|
+
* @param folder - リポジトリルートからの相対パス (例: "src/content/blog")
|
|
63
|
+
*/
|
|
64
|
+
listEntries(folder: string): Promise<CatalogEntry[]>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 指定パスのファイルを取得する。
|
|
68
|
+
* @returns ファイルの生テキスト内容と blob SHA
|
|
69
|
+
*/
|
|
70
|
+
getEntry(sourcePath: string): Promise<{ content: string; revision: string }>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ファイルを作成または更新して commit する。
|
|
74
|
+
* @param sha - 既存ファイルの場合は blob SHA を渡す (楽観的排他制御)。新規の場合は undefined。
|
|
75
|
+
*/
|
|
76
|
+
saveEntry(
|
|
77
|
+
sourcePath: string,
|
|
78
|
+
content: string,
|
|
79
|
+
options: {
|
|
80
|
+
sha?: string;
|
|
81
|
+
message: string;
|
|
82
|
+
}
|
|
83
|
+
): Promise<SaveResult>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ファイルを削除して commit する。
|
|
87
|
+
* @param sha - 削除対象の blob SHA
|
|
88
|
+
*/
|
|
89
|
+
deleteEntry(
|
|
90
|
+
sourcePath: string,
|
|
91
|
+
sha: string,
|
|
92
|
+
message: string
|
|
93
|
+
): Promise<void>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 指定ディレクトリ配下のアセット一覧を返す。
|
|
97
|
+
* @param folder - リポジトリルートからの相対パス (例: "public/uploads")
|
|
98
|
+
*/
|
|
99
|
+
listAssets(folder: string): Promise<AssetItem[]>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* バイナリアセット (画像など) をアップロードして commit する。
|
|
103
|
+
* @returns リポジトリ内の保存パス
|
|
104
|
+
*/
|
|
105
|
+
uploadAsset(
|
|
106
|
+
sourcePath: string,
|
|
107
|
+
content: Uint8Array,
|
|
108
|
+
message: string
|
|
109
|
+
): Promise<string>;
|
|
110
|
+
}
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED