@ryusenkei/maezae 0.15.0 → 0.17.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.
Files changed (34) hide show
  1. package/README.md +57 -0
  2. package/dist/body.js +312 -10
  3. package/dist/{declared-9_jI3JXH.js → declared-Qo-mWnDd.js} +154 -15
  4. package/dist/delivery.js +26 -2
  5. package/dist/model.js +1 -1
  6. package/dist/types/access/src/handoff.d.ts +1 -1
  7. package/dist/types/analytics/src/index.d.ts +1 -0
  8. package/dist/types/analytics/src/shared.d.ts +21 -0
  9. package/dist/types/body/src/derive.d.ts +1 -1
  10. package/dist/types/body/src/embed.d.ts +14 -0
  11. package/dist/types/body/src/html.d.ts +31 -0
  12. package/dist/types/body/src/index.d.ts +5 -2
  13. package/dist/types/body/src/library.d.ts +19 -0
  14. package/dist/types/body/src/markdown.d.ts +1 -1
  15. package/dist/types/body/src/normalize.d.ts +1 -1
  16. package/dist/types/body/src/profile.d.ts +34 -2
  17. package/dist/types/body/src/render.d.ts +29 -0
  18. package/dist/types/body/src/types.d.ts +46 -5
  19. package/dist/types/cms-contract/src/apiKeys.d.ts +27 -0
  20. package/dist/types/cms-contract/src/errors.d.ts +5 -3
  21. package/dist/types/cms-contract/src/import.d.ts +32 -1
  22. package/dist/types/cms-contract/src/index.d.ts +5 -4
  23. package/dist/types/cms-contract/src/labels.d.ts +5 -0
  24. package/dist/types/cms-contract/src/roles.d.ts +3 -1
  25. package/dist/types/cms-contract/src/views.d.ts +2 -2
  26. package/dist/types/cms-contract/src/workflow.d.ts +49 -0
  27. package/dist/types/model/src/index.d.ts +1 -1
  28. package/dist/types/model/src/model.d.ts +8 -2
  29. package/dist/types/sdk/src/body.d.ts +2 -2
  30. package/dist/types/sdk/src/delivery/changes.d.ts +23 -0
  31. package/dist/types/sdk/src/delivery/client.d.ts +4 -0
  32. package/dist/types/sdk/src/delivery/index.d.ts +1 -0
  33. package/dist/types/sdk/src/delivery.d.ts +2 -2
  34. package/package.json +1 -1
package/README.md CHANGED
@@ -19,6 +19,9 @@ maezae の SDK(導入先のサイト向け)
19
19
  - バインディング: `services: [{ "binding": "CMS", "service": "<CMS のサーバーの Worker>", "entrypoint": "Delivery" }]`
20
20
  - 媒体: URL のホスト名(媒体の管理画面のホスト名)
21
21
  - 鍵: 媒体の管理画面の「設定 → 連携」で発行した API の鍵
22
+ - 鍵の権限: 鍵ごとに型と操作(公開中の読み取り、下書きの読み取り)を選ぶ。権限のない型の経路は403(`code: "api_key_forbidden"`)
23
+ - `/types` はどれかの型、`/changes` と拡張機能の経路はすべての型の読み取りが要る。`fields` で展開する参照先の型と、`/bundle` の各 `path` も判定する
24
+ - 下書きの読み取りを許した鍵は、プレビューの署名なしで `drafts=1` を読める。許していない鍵は今までどおり署名が要る
22
25
  - 1件の形: `{ id, ...項目, _meta: { created_at, updated_at, published_at } }`、published_at は公開の管理がある型だけ(0.7.0 から)
23
26
  - 一覧: 本文の種類の項目を含めない、本文は1件の取得か `/bundle` で
24
27
  - 契約の版: パス `/content/v1` と、すべての応答のヘッダー `x-cms-version: 1`。非互換の変更はバージョン(版)を上げて行う(#495)
@@ -97,6 +100,37 @@ const cards = await client.page<Card>('/post', { limit: 20, fields, filter: { ti
97
100
  const one = await client.find<Card>('/post/p1', listParams({ fields }));
98
101
  ```
99
102
 
103
+ ## 変更の一覧(0.16.0 から)
104
+
105
+ `GET /content/v1/changes?since=<cursor>&limit=N` は、型をまたいだ行の変更を順に返す。検索や静的なビルドのために導入先が自分の側へ写すときの土台で、Webhook の取りこぼし(再送は約55時間半で止まる)もここで回収する
106
+
107
+ ```json
108
+ { "changes": [{ "type": "post", "id": "p1", "change": "upsert", "event": "published", "at": "2026-11-12T00:00:00.000Z" }], "next": "…", "more": false }
109
+ ```
110
+
111
+ - `since` なしは初回の全件の取得。公開中の全行を `event: "snapshot"` で返し、終わったら記録の続きに移る
112
+ - `change` は取得した時点の状態。`upsert` の行は配信 API で取り直し、`delete` の行は手元から消す。同じ行が何度も現れるので、行ごとに最後の状態で上書きする
113
+ - `event` は記録の出来事(`created`・`updated`・`deleted`・`published`・`unpublished`)。予約の公開は、公開日時を過ぎてから `published` で現れる
114
+ - `next` は変更がなくても返る。保存して次回の `since` に渡す。`more` が `true` ならすぐに続きを読む
115
+ - `limit` は1〜1000(既定は100)
116
+ - 記録は30日で消える。それより古い `since` は410(`code: "cursor_expired"`)を返すので、`since` なしで取り直す
117
+ - 公開の管理がある型の下書きの行の編集と、メディアは記録しない
118
+
119
+ `readChanges` は `more` がなくなるまで読み、ページごとに `apply` を呼んで次回の `since` を返す
120
+
121
+ ```ts
122
+ import { createDeliveryClient, DeliveryError, readChanges } from '@ryusenkei/maezae/delivery';
123
+
124
+ try {
125
+ since = await readChanges(client, since, async ({ changes }) => {
126
+ for (const c of changes) c.change === 'upsert' ? await upsert(c.type, c.id) : await remove(c.type, c.id);
127
+ });
128
+ } catch (e) {
129
+ if (e instanceof DeliveryError && e.status === 410) since = null; // 次回は全件から
130
+ else throw e;
131
+ }
132
+ ```
133
+
100
134
  ## 回数の制限
101
135
 
102
136
  - 対象は公開のエンドポイント(`https://api.maezae.app/<媒体>/content/v1/…`)だけ。上限は媒体と API の鍵ごとに1分で600回で、データセンターごとの近似で数える
@@ -125,10 +159,33 @@ const client = createDeliveryClient(cachedDelivery(delivery, { cache: caches.def
125
159
 
126
160
  公開の操作の反映は、CMS の側の遅れに加えて最大で `freshSec` 秒遅れる
127
161
 
162
+ ## 本文の描画(0.17.0 から)
163
+
164
+ `bodyToHtml` は本文のブロックの JSON を HTML にする。サイトがブロックを自分で解釈しなくて済む
165
+
166
+ - 文字と属性はエスケープし、リンク先は `https:`・`http:`・`mailto:`・ページ内だけを通す
167
+ - 文字の色・背景・大きさ: 本文の設定で宣言した名前(`style.color.name` など)があれば `named` の対応で、なければ元の CSS の値で描く。値は色と大きさの形だけを通す
168
+ - 名前のついた装飾(`style.styles`、microCMS のカスタム class): `class` に名前を出す(`styleClass` で変えられる)
169
+ - 埋め込み(`embed`): YouTube・Vimeo・X・Instagram・Spotify・Apple Podcasts の決まった https の URL だけを、制限つきの `iframe` で描く。本文は `iframe` の HTML を持たない
170
+ - 表の結合(`colspan`・`rowspan`)、見出しのセル、揃え、画像のリンクと横の位置、コードのファイル名を描く
171
+ - HTML のブロックは既定で描かない(`allowHtml: true` で生のまま)。コンポーネントは `component` が文字列を返せばそれ、`null` なら既定(`link-card`・`embed`・`gallery`)
172
+
173
+ ```ts
174
+ import { bodyToHtml } from '@ryusenkei/maezae/body';
175
+
176
+ const html = bodyToHtml(entry.body, {
177
+ image: (m) => ({ url: m.url, width: m.width, height: m.height, alt: m.alt }),
178
+ named: (kind, name) => (kind === 'color' ? `var(--color-${name})` : undefined),
179
+ });
180
+ ```
181
+
182
+ 章の分割などで見出しの文字を使うときは、`plainOf(heading.content)` で文字にする(取り込みは見出しの文字を変えない)
183
+
128
184
  ## プレビュー
129
185
 
130
186
  - CMS は媒体のサイトのオリジンへ、公開のページと同じパスで署名つきのリクエストを送信
131
187
  - 鍵: 媒体の管理画面の「設定 → 連携」のプレビューの署名の鍵
188
+ - 書きかけの値の画像: 配信 API の1件と同じ値(`id`・`kind`・`url`・`width`・`height`・`alt`・`hotspot`・`crop`)に参照の `asset` を足した形(0.17.0 から)
132
189
 
133
190
  ```ts
134
191
  import { SIGNED_HEADERS, verifyRequest } from '@ryusenkei/maezae/preview';
package/dist/body.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as coreComponentOf, n as declaredPropsZod, r as CITE } from "./declared-9_jI3JXH.js";
1
+ import { a as coreComponentOf, c as embedSrcOf, n as declaredPropsZod, o as EMBED_PROVIDERS, r as CITE, s as embedOfUrl } from "./declared-Qo-mWnDd.js";
2
2
  import { z } from "zod";
3
3
  // ../body/src/description.ts
4
4
  /** meta description の長さの上限(検索結果で省略されにくい字数の目安) */
@@ -14,6 +14,11 @@ function descriptionOf(excerpt, max = 120) {
14
14
  const end = head.lastIndexOf("。");
15
15
  return end > 0 ? head.slice(0, end + 1) : `${[...head].slice(0, max - 1).join("")}…`;
16
16
  }
17
+ // ../body/src/types.ts
18
+ /** 属性つきのセルか */
19
+ const isTableCell = (cell) => !Array.isArray(cell);
20
+ /** セルの中の段落(断片の列だけのセルは1段落) */
21
+ const cellParagraphs = (cell) => isTableCell(cell) ? cell.content : [cell];
17
22
  // ../body/src/derive.ts
18
23
  /** 読了時間の既定の分速(字/分) */
19
24
  const READING_SPEED = 500;
@@ -26,7 +31,7 @@ function richIn(body) {
26
31
  case "heading": return [b.content];
27
32
  case "list": return listRich(b);
28
33
  case "quote": return [...b.content];
29
- case "table": return [...b.head ?? [], ...b.rows.flat()];
34
+ case "table": return [...b.head ?? [], ...b.rows.flat().flatMap(cellParagraphs)];
30
35
  default: return [];
31
36
  }
32
37
  });
@@ -48,7 +53,12 @@ const UNREAD = /* @__PURE__ */ new Set([
48
53
  "name",
49
54
  "component",
50
55
  "variant",
51
- "marks"
56
+ "marks",
57
+ "style",
58
+ "align",
59
+ "colspan",
60
+ "rowspan",
61
+ "header"
52
62
  ]);
53
63
  const chars = (v) => typeof v === "string" ? v.length : Array.isArray(v) ? v.reduce((n, x) => n + chars(x), 0) : v && typeof v === "object" ? Object.entries(v).reduce((n, [k, x]) => n + (UNREAD.has(k) ? 0 : chars(x)), 0) : 0;
54
64
  /** 読了時間(分、本文の文字列の字数を分速 speed で割った概算、1分以上) */
@@ -70,8 +80,18 @@ const MARK_LABELS = {
70
80
  strike: "取り消し線",
71
81
  mark: "蛍光ペン",
72
82
  sup: "上付き",
73
- sub: "下付き"
83
+ sub: "下付き",
84
+ underline: "下線"
74
85
  };
86
+ const STYLE_LABELS = {
87
+ color: "文字の色",
88
+ background: "背景の色",
89
+ size: "文字の大きさ"
90
+ };
91
+ const CSS_COLOR = /^(#[0-9a-f]{3,8}|rgba?\(\s*[\d.%\s,/]+\)|hsla?\(\s*[\d.%\sdeg,/]+\)|[a-z]{3,20})$/i;
92
+ const CSS_SIZE = /^(\d{1,4}(\.\d{1,3})?(%|px|em|rem|pt)|xx-small|x-small|small|medium|large|x-large|xx-large|smaller|larger)$/i;
93
+ /** 元の CSS の値として描画してよいか(kind は色か大きさ) */
94
+ const isSafeCssValue = (kind, value) => (kind === "size" ? CSS_SIZE : CSS_COLOR).test(value.trim());
75
95
  /** 本文を設定で検査(合わない箇所をすべて返却) */
76
96
  function checkBody(body, profile) {
77
97
  const problems = [];
@@ -93,8 +113,37 @@ function checkBody(body, profile) {
93
113
  for (const m of inline.marks ?? []) if (!allowedMarks.has(m)) add(`${MARK_LABELS[m]}は使えない`);
94
114
  if (profile.combineMarks === false && (inline.marks?.length ?? 0) + (inline.link ? 1 : 0) > 1) add("装飾の重ね掛け・装飾つきのリンクは使えない");
95
115
  if (inline.link?.rel && !profile.linkRels?.includes(inline.link.rel)) add(`リンクの rel=${inline.link.rel} は使えない`);
116
+ if (inline.link?.target && !profile.linkTargets) add("リンクを別のタブで開く指定は使えない");
96
117
  if (inline.link && !isSafeHref(inline.link.href)) add(unsafeHrefMessage(inline.link.href));
118
+ if (inline.style) textStyle(inline.style, plain);
119
+ }
120
+ };
121
+ const textStyle = (style, plain) => {
122
+ if (plain) add("この場所では文字の見た目は使えない");
123
+ for (const kind of [
124
+ "color",
125
+ "background",
126
+ "size"
127
+ ]) {
128
+ const v = style[kind];
129
+ if (!v) continue;
130
+ const declared = profile.textStyle?.[kind];
131
+ if (!declared) add(`${STYLE_LABELS[kind]}は使えない`);
132
+ else if (v.name && !declared.some((d) => d.name === v.name)) add(`${STYLE_LABELS[kind]}の名前 ${v.name} は本文の設定にない`);
133
+ if (!v.name && !v.value) add(`${STYLE_LABELS[kind]}の名前と値がない`);
134
+ if (v.value && !isSafeCssValue(kind, v.value)) add(`${STYLE_LABELS[kind]}の値 ${v.value} は使えない`);
97
135
  }
136
+ for (const name of style.styles ?? []) if (!profile.textStyle?.styles?.some((d) => d.name === name)) add(`名前のついた装飾 ${name} は本文の設定にない`);
137
+ };
138
+ const align = (a) => {
139
+ if (a && !profile.align) add("揃えは使えない");
140
+ };
141
+ const cell = (c, head) => {
142
+ if (isTableCell(c)) {
143
+ if (!profile.table?.cells) add("属性つきの表のセルは使えない");
144
+ if ((c.colspan ?? 1) < 1 || (c.rowspan ?? 1) < 1) add("セルの結合の数は1以上");
145
+ c.content.forEach((r) => rich(r, (head || Boolean(c.header)) && profile.plain?.includes("tableHead")));
146
+ } else rich(c, head && profile.plain?.includes("tableHead"));
98
147
  };
99
148
  const variant = (v, allowed = []) => {
100
149
  if (v && !allowed.includes(v)) add(`表示の種類 ${v} は使えない`);
@@ -103,11 +152,13 @@ function checkBody(body, profile) {
103
152
  switch (b.type) {
104
153
  case "paragraph":
105
154
  variant(b.variant);
155
+ align(b.align);
106
156
  rich(b.content);
107
157
  break;
108
158
  case "heading":
109
159
  if (!profile.headings.includes(b.level)) add(`見出し${b.level}は使えない(使える見出し: ${profile.headings.join("・")})`);
110
160
  variant(b.variant);
161
+ align(b.align);
111
162
  rich(b.content, profile.plain?.includes("heading"));
112
163
  break;
113
164
  case "list": {
@@ -138,13 +189,22 @@ function checkBody(body, profile) {
138
189
  case "code":
139
190
  if (!profile.code) add("コードは使えない");
140
191
  else if (!profile.code.caption && b.caption) add("コードの説明は使えない");
192
+ else if (!profile.code.filename && b.filename) add("コードのファイル名は使えない");
141
193
  else if (b.lang && profile.code.componentLangs?.[b.lang]) add(`\`\`\`${b.lang} のコードは使えない(:::${profile.code.componentLangs[b.lang]} で囲む)`);
142
194
  variant(b.variant);
143
195
  break;
144
196
  case "image":
145
197
  if (!profile.image) add("画像は使えない");
198
+ else {
199
+ if (b.link && !profile.image.link) add("画像のリンクは使えない");
200
+ if (b.link && !isSafeHref(b.link.href)) add(unsafeHrefMessage(b.link.href));
201
+ if (b.align && !profile.image.align) add("画像の横の位置は使えない");
202
+ }
146
203
  variant(b.variant, profile.image?.variants);
147
204
  break;
205
+ case "file":
206
+ if (!profile.file) add("ファイルは使えない");
207
+ break;
148
208
  case "table": {
149
209
  const p = profile.table;
150
210
  if (!p) {
@@ -156,7 +216,7 @@ function checkBody(body, profile) {
156
216
  if (p.caption === "none" && b.caption) add("表の説明は使えない");
157
217
  variant(b.variant);
158
218
  b.head?.forEach((r) => rich(r, profile.plain?.includes("tableHead")));
159
- b.rows.forEach((row) => row.forEach((r) => rich(r)));
219
+ b.rows.forEach((row) => row.forEach((c) => cell(c, false)));
160
220
  break;
161
221
  }
162
222
  case "divider":
@@ -170,6 +230,196 @@ function checkBody(body, profile) {
170
230
  });
171
231
  return problems;
172
232
  }
233
+ // ../body/src/render.ts
234
+ const ESCAPES = {
235
+ "&": "&amp;",
236
+ "<": "&lt;",
237
+ ">": "&gt;",
238
+ "\"": "&quot;",
239
+ "'": "&#39;"
240
+ };
241
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ESCAPES[c]);
242
+ const attrs = (pairs) => Object.entries(pairs).filter(([, v]) => v !== void 0 && v !== null && v !== false && v !== "").map(([k, v]) => ` ${k}="${esc(String(v))}"`).join("");
243
+ const MARK_TAGS = {
244
+ strong: "strong",
245
+ em: "em",
246
+ code: "code",
247
+ strike: "s",
248
+ mark: "mark",
249
+ sup: "sup",
250
+ sub: "sub",
251
+ underline: "u"
252
+ };
253
+ /** 外側から内側の順 */
254
+ const MARK_ORDER = [
255
+ "strong",
256
+ "em",
257
+ "strike",
258
+ "underline",
259
+ "mark",
260
+ "sup",
261
+ "sub",
262
+ "code"
263
+ ];
264
+ const CSS_PROPERTY = {
265
+ color: "color",
266
+ background: "background-color",
267
+ size: "font-size"
268
+ };
269
+ /** iframe の許可(サービスの再生に要るものだけ) */
270
+ const EMBED_SANDBOX = "allow-scripts allow-same-origin allow-popups allow-presentation";
271
+ const EMBED_ALLOW = "encrypted-media; picture-in-picture; fullscreen";
272
+ /** 本文を HTML に */
273
+ function bodyToHtml(body, options) {
274
+ const styleAttr = (style) => {
275
+ const decls = Object.keys(CSS_PROPERTY).flatMap((kind) => {
276
+ const v = style[kind];
277
+ if (!v) return [];
278
+ const value = (v.name ? options.named?.(kind, v.name) : void 0) ?? v.value;
279
+ return value && isSafeCssValue(kind, value) ? [`${CSS_PROPERTY[kind]}: ${value}`] : [];
280
+ });
281
+ const classes = (style.styles ?? []).map((n) => options.styleClass?.(n) ?? n);
282
+ return attrs({
283
+ class: classes.join(" "),
284
+ style: decls.join("; ")
285
+ });
286
+ };
287
+ const linkOpen = (link) => isSafeHref(link.href) ? `<a${attrs({
288
+ href: link.href,
289
+ title: link.title,
290
+ target: link.target,
291
+ rel: link.target ? [.../* @__PURE__ */ new Set([...link.rel?.split(/\s+/) ?? [], "noopener"])].join(" ") : link.rel
292
+ })}>` : null;
293
+ const inline = (i) => {
294
+ if (typeof i === "string") return esc(i).replaceAll("\n", "<br>");
295
+ if ("component" in i) return options.inlineComponent?.(i.component, i.props ?? {}, i.text ?? "") ?? esc(i.text ?? "");
296
+ let html = esc(i.text).replaceAll("\n", "<br>");
297
+ for (const m of [...MARK_ORDER].reverse()) if (i.marks?.includes(m)) html = `<${MARK_TAGS[m]}>${html}</${MARK_TAGS[m]}>`;
298
+ if (i.style) {
299
+ const a = styleAttr(i.style);
300
+ if (a) html = `<span${a}>${html}</span>`;
301
+ }
302
+ if (i.link) {
303
+ const open = linkOpen(i.link);
304
+ if (open) html = `${open}${html}</a>`;
305
+ }
306
+ return html;
307
+ };
308
+ const rich = (r) => r.map(inline).join("");
309
+ const list = (l) => {
310
+ const tag = l.ordered ? "ol" : "ul";
311
+ return `<${tag}${attrs({ class: l.variant })}>${l.items.map((item) => `<li>${rich(item.content)}${item.children ? list(item.children) : ""}</li>`).join("")}</${tag}>`;
312
+ };
313
+ const cell = (c, header) => {
314
+ if (!isTableCell(c)) return `<${header ? "th" : "td"}>${rich(c)}</${header ? "th" : "td"}>`;
315
+ const tag = c.header || header ? "th" : "td";
316
+ const content = c.content.length > 1 ? c.content.map((p) => `<p>${rich(p)}</p>`).join("") : rich(c.content[0] ?? []);
317
+ return `<${tag}${attrs({
318
+ colspan: c.colspan,
319
+ rowspan: c.rowspan,
320
+ style: c.align ? `text-align: ${c.align}` : void 0
321
+ })}>${content}</${tag}>`;
322
+ };
323
+ const imageHtml = (media, alt) => {
324
+ const img = options.image(media);
325
+ if (!img || !isSafeHref(img.url)) return null;
326
+ return `<img${attrs({
327
+ src: img.url,
328
+ width: img.width,
329
+ height: img.height,
330
+ alt: alt ?? img.alt ?? "",
331
+ loading: "lazy"
332
+ })}>`;
333
+ };
334
+ const component = (b) => {
335
+ const custom = options.component?.(b, render);
336
+ if (custom !== null && custom !== void 0) return custom;
337
+ const p = b.props ?? {};
338
+ switch (b.name) {
339
+ case "embed": {
340
+ const provider = p.provider;
341
+ const id = typeof p.id === "string" ? p.id : "";
342
+ if (!id) return "";
343
+ const src = embedSrcOf({
344
+ provider,
345
+ id,
346
+ ...typeof p.start === "number" ? { start: p.start } : {}
347
+ });
348
+ return `<div${attrs({
349
+ class: "embed",
350
+ "data-provider": provider
351
+ })}><iframe${attrs({
352
+ src,
353
+ title: typeof p.title === "string" ? p.title : provider,
354
+ width: p.width,
355
+ height: p.height,
356
+ loading: "lazy",
357
+ sandbox: EMBED_SANDBOX,
358
+ allow: EMBED_ALLOW,
359
+ referrerpolicy: "strict-origin-when-cross-origin"
360
+ })}></iframe></div>`;
361
+ }
362
+ case "link-card": {
363
+ const href = typeof p.href === "string" ? p.href : void 0;
364
+ if (!href || !isSafeHref(href)) return "";
365
+ const image = p.image ? imageHtml(p.image, "") : null;
366
+ const text = (cls, v) => typeof v === "string" && v ? `<span${attrs({ class: cls })}>${esc(v)}</span>` : "";
367
+ return `<a${attrs({
368
+ class: "link-card",
369
+ href
370
+ })}>${image ?? ""}${text("link-card-title", p.title)}${text("link-card-desc", p.desc)}${text("link-card-site", p.site)}</a>`;
371
+ }
372
+ case "gallery": return `<div class="gallery">${(Array.isArray(p.items) ? p.items : []).map((i) => `<figure>${imageHtml(i.media, void 0) ?? ""}${i.caption ? `<figcaption>${esc(i.caption)}</figcaption>` : ""}</figure>`).join("")}</div>`;
373
+ default: return "";
374
+ }
375
+ };
376
+ const block = (b) => {
377
+ switch (b.type) {
378
+ case "paragraph": return `<p${attrs({
379
+ class: b.variant,
380
+ style: b.align ? `text-align: ${b.align}` : void 0
381
+ })}>${rich(b.content)}</p>`;
382
+ case "heading": return `<h${b.level}${attrs({
383
+ id: b.id,
384
+ class: b.variant,
385
+ style: b.align ? `text-align: ${b.align}` : void 0
386
+ })}>${rich(b.content)}</h${b.level}>`;
387
+ case "list": return list(b);
388
+ case "quote": {
389
+ const cite = b.cite ? `<cite>${b.cite.href && isSafeHref(b.cite.href) ? `<a${attrs({ href: b.cite.href })}>${esc(b.cite.label)}</a>` : esc(b.cite.label)}</cite>` : "";
390
+ return `<blockquote${attrs({ class: b.variant })}>${b.content.map((p) => `<p>${rich(p)}</p>`).join("")}${cite}</blockquote>`;
391
+ }
392
+ case "code": {
393
+ const pre = `<pre${attrs({ "data-filename": b.filename })}><code${attrs({ class: b.lang ? `language-${b.lang}` : void 0 })}>${esc(b.code)}</code></pre>`;
394
+ return b.caption ? `<figure${attrs({ class: b.variant })}>${pre}<figcaption>${esc(b.caption)}</figcaption></figure>` : pre;
395
+ }
396
+ case "image": {
397
+ const img = imageHtml(b.media, b.alt);
398
+ if (!img) return "";
399
+ const open = b.link ? linkOpen(b.link) : null;
400
+ return `<figure${attrs({
401
+ class: b.variant,
402
+ style: b.align ? `text-align: ${b.align}` : void 0
403
+ })}>${open ? `${open}${img}</a>` : img}${b.caption ? `<figcaption>${esc(b.caption)}</figcaption>` : ""}</figure>`;
404
+ }
405
+ case "table": {
406
+ const head = b.head ? `<thead><tr>${b.head.map((h) => cell(h, true)).join("")}</tr></thead>` : "";
407
+ const rows = `<tbody>${b.rows.map((r) => `<tr>${r.map((c) => cell(c, false)).join("")}</tr>`).join("")}</tbody>`;
408
+ return `<table${attrs({ class: b.variant })}>${b.caption ? `<caption>${esc(b.caption)}</caption>` : ""}${head}${rows}</table>`;
409
+ }
410
+ case "file": {
411
+ const f = options.file?.(b.media);
412
+ if (!f || !isSafeHref(f.url)) return "";
413
+ return `<p class="file"><a${attrs({ href: f.url })}>${esc(b.label ?? f.url)}</a></p>`;
414
+ }
415
+ case "divider": return "<hr>";
416
+ case "html": return options.allowHtml ? b.html : "";
417
+ case "component": return component(b);
418
+ }
419
+ };
420
+ const render = (blocks) => blocks.map(block).join("");
421
+ return render(body);
422
+ }
173
423
  // ../body/src/schema.ts
174
424
  const MARKS = [
175
425
  "strong",
@@ -178,7 +428,14 @@ const MARKS = [
178
428
  "strike",
179
429
  "mark",
180
430
  "sup",
181
- "sub"
431
+ "sub",
432
+ "underline"
433
+ ];
434
+ const ALIGNS = [
435
+ "left",
436
+ "center",
437
+ "right",
438
+ "justify"
182
439
  ];
183
440
  const CITE_TEXT = /:cite\{#([^\s}]+)\}/g;
184
441
  const liftCites = (text) => text.split(CITE_TEXT).flatMap((piece, i) => i % 2 ? [{
@@ -188,13 +445,26 @@ const liftCites = (text) => text.split(CITE_TEXT).flatMap((piece, i) => i % 2 ?
188
445
  const link = z.object({
189
446
  href: z.string().min(1, "リンク先が未入力"),
190
447
  title: z.string().optional(),
191
- rel: z.string().optional()
448
+ rel: z.string().optional(),
449
+ target: z.literal("_blank").optional()
450
+ });
451
+ const named = z.object({
452
+ name: z.string().min(1).optional(),
453
+ value: z.string().min(1).optional()
454
+ });
455
+ const textStyle = z.object({
456
+ color: named.optional(),
457
+ background: named.optional(),
458
+ size: named.optional(),
459
+ styles: z.array(z.string().min(1)).optional()
192
460
  });
193
461
  const text = z.object({
194
462
  text: z.string(),
195
463
  marks: z.array(z.enum(MARKS)).optional(),
196
- link: link.optional()
464
+ link: link.optional(),
465
+ style: textStyle.optional()
197
466
  });
467
+ const align = z.enum(ALIGNS).optional();
198
468
  /** 部品の props の検査(名前で核のコンポーネント、なければ宣言した部品、どちらもないか種類が違えば null) */
199
469
  function propsOf(name, inline, media, declared) {
200
470
  const core = coreComponentOf(name);
@@ -262,12 +532,32 @@ function blockSchemaOf(profile, media, declared = []) {
262
532
  ]),
263
533
  content: rich,
264
534
  id: z.string().optional(),
535
+ align,
265
536
  variant
266
537
  });
538
+ const span = z.number().int().min(1).max(1e3).optional();
539
+ const tableCell = z.object({
540
+ content: z.array(rich),
541
+ header: z.boolean().optional(),
542
+ colspan: span,
543
+ rowspan: span,
544
+ align
545
+ });
546
+ const cell = z.unknown().transform((value, ctx) => {
547
+ const r = (Array.isArray(value) ? rich : tableCell).safeParse(value);
548
+ if (r.success) return r.data;
549
+ for (const issue of r.error.issues) ctx.addIssue({
550
+ code: "custom",
551
+ message: issue.message,
552
+ path: issue.path
553
+ });
554
+ return z.NEVER;
555
+ });
267
556
  return z.discriminatedUnion("type", [
268
557
  z.object({
269
558
  type: z.literal("paragraph"),
270
559
  content: rich,
560
+ align,
271
561
  variant
272
562
  }),
273
563
  heading.refine((h) => h.content.some((i) => typeof i === "string" ? i.trim() : "text" in i && i.text?.trim()), "見出しが未入力"),
@@ -286,6 +576,7 @@ function blockSchemaOf(profile, media, declared = []) {
286
576
  code: z.string(),
287
577
  lang: z.string().optional(),
288
578
  caption: z.string().optional(),
579
+ filename: z.string().optional(),
289
580
  variant
290
581
  }),
291
582
  z.object({
@@ -293,15 +584,26 @@ function blockSchemaOf(profile, media, declared = []) {
293
584
  media,
294
585
  alt: z.string().optional(),
295
586
  caption: z.string().optional(),
587
+ link: link.optional(),
588
+ align: z.enum([
589
+ "left",
590
+ "center",
591
+ "right"
592
+ ]).optional(),
296
593
  variant
297
594
  }),
298
595
  z.object({
299
596
  type: z.literal("table"),
300
597
  head: z.array(rich).optional(),
301
- rows: z.array(z.array(rich)),
598
+ rows: z.array(z.array(cell)),
302
599
  caption: z.string().optional(),
303
600
  variant
304
601
  }),
602
+ z.object({
603
+ type: z.literal("file"),
604
+ media,
605
+ label: z.string().optional()
606
+ }),
305
607
  z.object({ type: z.literal("divider") }),
306
608
  z.object({
307
609
  type: z.literal("html"),
@@ -344,4 +646,4 @@ function blockSchemaOf(profile, media, declared = []) {
344
646
  return out;
345
647
  });
346
648
  }
347
- export { DESCRIPTION_MAX, READING_SPEED, blockSchemaOf, citesOf, descriptionOf, excerptOf, isSafeHref, linksOf, plainOf, readMinOf };
649
+ export { DESCRIPTION_MAX, EMBED_PROVIDERS, READING_SPEED, blockSchemaOf, bodyToHtml, cellParagraphs, citesOf, descriptionOf, embedOfUrl, embedSrcOf, excerptOf, isSafeCssValue, isSafeHref, isTableCell, linksOf, plainOf, readMinOf };