@ryusenkei/maezae 0.16.0 → 0.18.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 (35) hide show
  1. package/README.md +44 -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 +43 -2
  5. package/dist/model.js +4 -2
  6. package/dist/types/analytics/src/index.d.ts +1 -0
  7. package/dist/types/analytics/src/shared.d.ts +21 -0
  8. package/dist/types/body/src/derive.d.ts +1 -1
  9. package/dist/types/body/src/embed.d.ts +14 -0
  10. package/dist/types/body/src/html.d.ts +31 -0
  11. package/dist/types/body/src/index.d.ts +5 -2
  12. package/dist/types/body/src/library.d.ts +19 -0
  13. package/dist/types/body/src/markdown.d.ts +1 -1
  14. package/dist/types/body/src/normalize.d.ts +1 -1
  15. package/dist/types/body/src/profile.d.ts +34 -2
  16. package/dist/types/body/src/render.d.ts +29 -0
  17. package/dist/types/body/src/types.d.ts +46 -5
  18. package/dist/types/cms-contract/src/apiKeys.d.ts +27 -0
  19. package/dist/types/cms-contract/src/assets.d.ts +2 -0
  20. package/dist/types/cms-contract/src/errors.d.ts +5 -3
  21. package/dist/types/cms-contract/src/import.d.ts +33 -1
  22. package/dist/types/cms-contract/src/index.d.ts +6 -5
  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/asset.d.ts +8 -2
  28. package/dist/types/model/src/index.d.ts +2 -2
  29. package/dist/types/model/src/mediaUrl.d.ts +20 -2
  30. package/dist/types/model/src/model.d.ts +12 -4
  31. package/dist/types/sdk/src/body.d.ts +2 -2
  32. package/dist/types/sdk/src/delivery/index.d.ts +1 -1
  33. package/dist/types/sdk/src/delivery/values.d.ts +6 -2
  34. package/dist/types/sdk/src/delivery.d.ts +1 -1
  35. 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)
@@ -156,10 +159,51 @@ const client = createDeliveryClient(cachedDelivery(delivery, { cache: caches.def
156
159
 
157
160
  公開の操作の反映は、CMS の側の遅れに加えて最大で `freshSec` 秒遅れる
158
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
+
184
+ ## 非公開のメディア(0.18.0 から)
185
+
186
+ 非公開の項目(型の宣言で `visibility: 'private'` のメディアと本文)の画像とファイルは、署名つき・期限つきの URL でだけ配信する。配信 API は `visibility: 'private'` と署名のない `url` を返すので、権利のある人に見せるときにサイトのサーバーが署名する
187
+
188
+ - 鍵: 媒体の管理画面の「設定 → 連携」の非公開のメディアの署名の鍵。サイトの秘密値に入れ、ブラウザに出さない
189
+ - 期限: `expiresInSec`(既定は3600秒)。ページを表示するたびに署名し、写しに署名つきの URL を保存しない
190
+ - 画像の変換のクエリ(`w` など)は署名の後に足してよい(署名の対象はパスと期限だけ)
191
+ - 署名のない URL は404、期限切れと別の鍵の署名は403。鍵を作り直すと古い鍵の署名はすぐ通らない
192
+
193
+ ```ts
194
+ import { signMediaUrl, withImageParams } from '@ryusenkei/maezae/delivery';
195
+
196
+ const src = image.visibility === 'private' ? await signMediaUrl(image.url, env.MEDIA_SIGNING_KEY, { expiresInSec: 1800 }) : image.url;
197
+ const thumb = withImageParams(src, { w: 400, auto: 'format' });
198
+ ```
199
+
200
+ ファイル(`kind: 'file'`): `url` は `/files/<媒体>/<ID>.<拡張子>`、`name`(元のファイル名)・`mimeType`・`size` を持つ。本文のファイルのブロックも同じ実体を参照する
201
+
159
202
  ## プレビュー
160
203
 
161
204
  - CMS は媒体のサイトのオリジンへ、公開のページと同じパスで署名つきのリクエストを送信
162
205
  - 鍵: 媒体の管理画面の「設定 → 連携」のプレビューの署名の鍵
206
+ - 書きかけの値の画像: 配信 API の1件と同じ値(`id`・`kind`・`url`・`width`・`height`・`alt`・`hotspot`・`crop`)に参照の `asset` を足した形(0.17.0 から)
163
207
 
164
208
  ```ts
165
209
  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 };