@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.
- package/README.md +44 -0
- package/dist/body.js +312 -10
- package/dist/{declared-9_jI3JXH.js → declared-Qo-mWnDd.js} +154 -15
- package/dist/delivery.js +43 -2
- package/dist/model.js +4 -2
- package/dist/types/analytics/src/index.d.ts +1 -0
- package/dist/types/analytics/src/shared.d.ts +21 -0
- package/dist/types/body/src/derive.d.ts +1 -1
- package/dist/types/body/src/embed.d.ts +14 -0
- package/dist/types/body/src/html.d.ts +31 -0
- package/dist/types/body/src/index.d.ts +5 -2
- package/dist/types/body/src/library.d.ts +19 -0
- package/dist/types/body/src/markdown.d.ts +1 -1
- package/dist/types/body/src/normalize.d.ts +1 -1
- package/dist/types/body/src/profile.d.ts +34 -2
- package/dist/types/body/src/render.d.ts +29 -0
- package/dist/types/body/src/types.d.ts +46 -5
- package/dist/types/cms-contract/src/apiKeys.d.ts +27 -0
- package/dist/types/cms-contract/src/assets.d.ts +2 -0
- package/dist/types/cms-contract/src/errors.d.ts +5 -3
- package/dist/types/cms-contract/src/import.d.ts +33 -1
- package/dist/types/cms-contract/src/index.d.ts +6 -5
- package/dist/types/cms-contract/src/labels.d.ts +5 -0
- package/dist/types/cms-contract/src/roles.d.ts +3 -1
- package/dist/types/cms-contract/src/views.d.ts +2 -2
- package/dist/types/cms-contract/src/workflow.d.ts +49 -0
- package/dist/types/model/src/asset.d.ts +8 -2
- package/dist/types/model/src/index.d.ts +2 -2
- package/dist/types/model/src/mediaUrl.d.ts +20 -2
- package/dist/types/model/src/model.d.ts +12 -4
- package/dist/types/sdk/src/body.d.ts +2 -2
- package/dist/types/sdk/src/delivery/index.d.ts +1 -1
- package/dist/types/sdk/src/delivery/values.d.ts +6 -2
- package/dist/types/sdk/src/delivery.d.ts +1 -1
- 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-
|
|
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((
|
|
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
|
+
"&": "&",
|
|
236
|
+
"<": "<",
|
|
237
|
+
">": ">",
|
|
238
|
+
"\"": """,
|
|
239
|
+
"'": "'"
|
|
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(
|
|
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 };
|