@ryusenkei/maezae 0.0.0-stage → 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.
@@ -0,0 +1,335 @@
1
+ import { n as BodyProfile } from "./index-D11fUsoi.js";
2
+ import { z } from "zod";
3
+ /** ページの行の値(項目の key と値、表の列・集計の値も含む) */
4
+ type PageEntry = Readonly<Record<string, unknown>>;
5
+ /**
6
+ * 公開中の条件の1つ(key は行の値の key)
7
+ * - in・notIn: 値の文字列が一覧にある・ない
8
+ * - truthy: 値が真(公開のチェックなど)
9
+ * - notFuture: 値が空か、今の時刻以前(予約の公開日時)
10
+ */
11
+ type PageRule = Readonly<{
12
+ key: string;
13
+ } & ({
14
+ in: readonly string[];
15
+ } | {
16
+ notIn: readonly string[];
17
+ } | {
18
+ truthy: true;
19
+ } | {
20
+ notFuture: true;
21
+ })>;
22
+ /** ページの宣言(when は選択肢の項目による条件、path は {key} を含む雛形) */
23
+ type PageDef = Readonly<{
24
+ when?: FieldCondition;
25
+ path: string;
26
+ public?: readonly PageRule[];
27
+ }>;
28
+ /** 画像の枠(ratio は CSS の aspect-ratio の書式、size は推奨の寸法、label は画像の呼び名) */
29
+ type ImageFrame = Readonly<{
30
+ ratio: string;
31
+ size: Readonly<{
32
+ width: number;
33
+ height: number;
34
+ }>;
35
+ label: string;
36
+ }>;
37
+ /** 画像の枠の宣言(when は同じ階層にある選択肢の項目による条件) */
38
+ type ImageFrameDef = ImageFrame & Readonly<{
39
+ when?: FieldCondition;
40
+ }>;
41
+ /** ページのパス(ページのない型・行は null、公開の状態は見ない) */
42
+ declare function pagePathOf(type: ContentType, entry: PageEntry): string | null;
43
+ /** 公開中のページのパス(公開前・予約中・ページのない行は null、now は今の時刻の ISO 文字列) */
44
+ declare function publicPathOf(type: ContentType, entry: PageEntry, now: string): string | null;
45
+ /** 必須になる時点(page は行のページが公開中になるとき、false は任意) */
46
+ type Requirement = 'save' | 'submit' | 'page' | false;
47
+ type FieldBase = Readonly<{
48
+ key: string;
49
+ label: string;
50
+ /** 書き手と AI への説明(MCP の項目の説明にも使用) */
51
+ description?: string;
52
+ required?: Requirement;
53
+ when?: FieldCondition;
54
+ /** 配信しない項目(CMS の中だけで使用) */
55
+ private?: boolean;
56
+ /** 行のすべてを変更できるアカウントだけが編集(自分のものだけを変更できるアカウントの保存では保存済みの値のまま) */
57
+ adminOnly?: boolean;
58
+ /** 未定の null にできる期限: 型の最上位の項目 key に値が入ると未定なし(方針のページの制定日など) */
59
+ pendingUntil?: string;
60
+ }>;
61
+ /** 項目の対象の条件(同じ階層にある選択肢の項目 key の値が in のいずれか) */
62
+ type FieldCondition = Readonly<{
63
+ key: string;
64
+ in: readonly string[];
65
+ }>;
66
+ /** 選択肢の値の調子(CMS の札の色、positive は進行中・提供中、caution は途中・注意、neutral は終了・既定) */
67
+ type OptionTone = 'positive' | 'caution' | 'neutral';
68
+ /** セレクトの選択肢(tone は札の色、なしは neutral、restricted は型の公開が可のアカウントだけが選択し AI は選択不可) */
69
+ type SelectOption = Readonly<{
70
+ value: string;
71
+ label: string;
72
+ tone?: OptionTone;
73
+ restricted?: boolean;
74
+ }>;
75
+ /** ほかの項目の値で決まる値(同じ階層の選択肢の項目 from の値ごとの値、表にない値は otherwise) */
76
+ type FieldDerive = Readonly<{
77
+ from: string;
78
+ map: Readonly<Record<string, string>>;
79
+ otherwise: string;
80
+ }>;
81
+ /** 参照先の値で固定する値(同じ階層の参照の項目 ref が指す行の項目 key の値ごとの値、表にない値は固定なし) */
82
+ type FieldFixedBy = Readonly<{
83
+ ref: string;
84
+ key: string;
85
+ map: Readonly<Record<string, string>>;
86
+ }>;
87
+ /**
88
+ * 被参照の件数: 型 from で公開中の行のうち、参照の項目 field でこの行を指す数
89
+ * - key: 値の名前(ページの公開中の条件などで使用)
90
+ */
91
+ type CountDef = Readonly<{
92
+ key: string;
93
+ from: string;
94
+ field: string;
95
+ }>;
96
+ /** 値の件数の下限と上限(multiple の項目だけ、空は必須の判定に任せる) */
97
+ type Counts = Readonly<{
98
+ minItems?: number;
99
+ maxItems?: number;
100
+ }>;
101
+ /**
102
+ * 参照の項目で保存できる条件(参照先の行が満たす条件、DB を参照するため CMS のサーバーが保存のときに検査)
103
+ * - unique: 同じ行を重ねて選択できない(checkFields で検査)
104
+ * - published: 参照先は公開中の行だけ
105
+ * - linkedBy: 参照先の項目 key がこの行を指す行だけ(本人の記事など、参照先の複数の参照は含むかで判定、作成のときは選択不可)
106
+ */
107
+ type ReferenceRules = Readonly<{
108
+ unique?: boolean;
109
+ published?: boolean;
110
+ linkedBy?: string;
111
+ }>;
112
+ /** メディアの種類(コアの型 image・video の id と同じ) */
113
+ type MediaKind = 'image' | 'video';
114
+ /** 項目の定義 */
115
+ type FieldDef = FieldBase & Readonly<{
116
+ type: 'text' | 'textarea';
117
+ maxLength?: number;
118
+ minLength?: number; /** 形式(正規表現、空でない値が全体で一致) */
119
+ pattern?: string; /** 文字数の目安(超えても保存可、画面で表示) */
120
+ guideLength?: number; /** 文字の配列(段落の並びなど) */
121
+ multiple?: boolean;
122
+ } & Counts | {
123
+ type: 'number';
124
+ min?: number;
125
+ max?: number;
126
+ } | {
127
+ type: 'checkbox';
128
+ } | {
129
+ type: 'date' | 'datetime';
130
+ } | ({
131
+ type: 'select';
132
+ options: readonly SelectOption[]; /** 新しい行の初期値(なしは先頭の選択肢) */
133
+ initial?: string;
134
+ multiple?: boolean;
135
+ derive?: FieldDerive;
136
+ fixedBy?: FieldFixedBy;
137
+ } & Counts) | ({
138
+ type: 'reference';
139
+ to: string;
140
+ multiple?: boolean; /** 候補の絞り込み(参照先のコアの項目の key と値、例: 企画の種類が特集) */
141
+ where?: Readonly<Record<string, string>>; /** 候補にない名前を追加の依頼で付けられる(参照先は一覧で管理する汎用の型、承認で行を作成) */
142
+ requests?: boolean; /** 値が空のときの表示名(持ち主のいない行の名義など) */
143
+ emptyLabel?: string;
144
+ } & ReferenceRules & Counts) | ({
145
+ type: 'media'; /** 受け付けるメディアの種類(なしはすべて) */
146
+ accept?: readonly MediaKind[];
147
+ multiple?: boolean; /** 画像の枠(先頭から条件に合うもの、page.ts) */
148
+ frames?: readonly ImageFrameDef[];
149
+ } & Counts) | {
150
+ type: 'body';
151
+ profile: BodyProfile;
152
+ } | ({
153
+ type: 'group'; /** まとまりの中の項目(Sanity の object、microCMS のカスタムフィールド) */
154
+ fields: readonly FieldDef[];
155
+ multiple?: boolean;
156
+ } & Counts)>;
157
+ /** 型の形式(リスト形式は行を複数、オブジェクト形式は行を1つだけ) */
158
+ type ContentFormat = 'list' | 'object';
159
+ /** 公開の管理(なし・下書きと公開・レビューあり) */
160
+ type Publishing = 'none' | 'draft' | 'review';
161
+ /**
162
+ * 型の定義
163
+ * - id: コアの型の ID か、追加の型の ID
164
+ * - core: コアで固定の項目、fields: 導入先の追加の項目
165
+ * - format: 形式(なしはリスト形式)、オブジェクト形式はコアの項目なし
166
+ * - publishing: 公開の管理(なしは管理なし)
167
+ * - table: コアの項目を持つ型の表
168
+ * - pages: サイトのページの URL と公開中の条件(なしはページのない型、page.ts)
169
+ */
170
+ /**
171
+ * コンテンツタイプ
172
+ * - counts: 被参照の件数(ページの公開中の条件・ページの公開で必須の判定に使用)
173
+ * - og: サイトの OG 画像のパスの雛形({key} は行の値、管理画面の OG 画像のプレビュー)
174
+ */
175
+ type ContentType = Readonly<{
176
+ id: string;
177
+ label: string;
178
+ description?: string;
179
+ format?: ContentFormat;
180
+ publishing?: Publishing;
181
+ table?: string;
182
+ core?: readonly FieldDef[];
183
+ fields: readonly FieldDef[];
184
+ pages?: readonly PageDef[];
185
+ counts?: readonly CountDef[];
186
+ og?: string;
187
+ }>;
188
+ /** コンテンツスキーマ(導入先ごとに1つ) */
189
+ type ContentModel = Readonly<{
190
+ types: readonly ContentType[];
191
+ }>;
192
+ /** コンテンツスキーマの型(id から、なければ例外: 導入先の設定の誤り) */
193
+ declare function typeOf(model: ContentModel, id: string): ContentType;
194
+ /**
195
+ * 見せたい点(Sanity の hotspot、値はすべて画像に対する割合の0〜1、左上が0,0)
196
+ * - x・y: 中心(枠に合わせて切り抜くときの中心、なしは中央)
197
+ * - width・height: 範囲の大きさ(今の表示は中心だけを使用)
198
+ * - 切り抜き crop がある画像: CMS で保存する値は元の画像に対する割合、表示の Media では切り抜いた後の画像に対する割合
199
+ */
200
+ type Hotspot = Readonly<{
201
+ x: number;
202
+ y: number;
203
+ width?: number;
204
+ height?: number;
205
+ }>;
206
+ /** 切り抜き(Sanity の crop、上下左右の各辺から削る量を画像に対する割合の0〜1で) */
207
+ type Crop = Readonly<{
208
+ top: number;
209
+ bottom: number;
210
+ left: number;
211
+ right: number;
212
+ }>;
213
+ /** サイトのビルドで読み込んだ画像(Astro の ImageMetadata と同じ形、画像の最適化の対象) */
214
+ type LocalImage = Readonly<{
215
+ src: string;
216
+ width: number;
217
+ height: number;
218
+ format: string;
219
+ }>;
220
+ /** 実体の共通の項目(ID は UUID で R2 のキーと同じ、sha256 は内容のハッシュで同じ画像の重複を防止、ファイル名は保存しない) */
221
+ type AssetBase = Readonly<{
222
+ id: string;
223
+ mimeType: string;
224
+ size: number;
225
+ sha256: string | null;
226
+ width: number;
227
+ height: number;
228
+ alt: string;
229
+ hotspot?: Hotspot;
230
+ crop?: Crop;
231
+ createdAt: string;
232
+ }>;
233
+ /** 画像の実体(Sanity の sanity.imageAsset、代替テキストと hotspot は画像ごとに1つ) */
234
+ type ImageAsset = AssetBase & Readonly<{
235
+ kind: 'image';
236
+ }>;
237
+ /** 動画の実体(poster は代わりに表示する画像の実体の ID、配信と再生は最初に使用する場所が決まったときに実装) */
238
+ type VideoAsset = AssetBase & Readonly<{
239
+ kind: 'video';
240
+ duration?: number;
241
+ poster?: string;
242
+ }>;
243
+ /** 実体(CMS のメディアにある1件) */
244
+ type Asset = ImageAsset | VideoAsset;
245
+ /** 使用する側が保存する実体の参照(Sanity の image の asset、表示のときに実体の寸法・代替テキスト・hotspot を合わせて Media に解決) */
246
+ type AssetRef = Readonly<{
247
+ asset: string;
248
+ }>;
249
+ /**
250
+ * 表示する素材の共通の項目
251
+ * - src: URL(CMS の実体)か、ビルドで読み込んだ画像
252
+ * - width・height: 元の寸法(表示の比と、読み込み前の場所の確保)
253
+ * - alt: 代替テキスト(飾りの画像は空文字)
254
+ */
255
+ type MediaBase = Readonly<{
256
+ src: string | LocalImage;
257
+ width: number;
258
+ height: number;
259
+ alt: string;
260
+ hotspot?: Hotspot;
261
+ }>;
262
+ /**
263
+ * 表示の素材(使用する側の型で共通、実体の参照 AssetRef を解決した値)
264
+ * - `image`: 画像
265
+ * - `video`: 動画(poster は読み込み前・動きを抑える設定・OG で代わりに表示する画像、再生の仕方は使用する場所で決定)
266
+ */
267
+ type Media = MediaBase & Readonly<{
268
+ kind: 'image';
269
+ } | {
270
+ kind: 'video';
271
+ poster: MediaBase;
272
+ }>;
273
+ /** 解決後の素材の検査(src は URL、ビルドで読み込んだ画像は JSON に含めないため対象外) */
274
+ declare const mediaSchema: z.ZodType<Media>;
275
+ /** 静止画として表示する画像(動画は代わりの画像 poster) */
276
+ declare const stillOf: (media: Media) => Readonly<{
277
+ src: string | LocalImage;
278
+ width: number;
279
+ height: number;
280
+ alt: string;
281
+ hotspot?: Hotspot;
282
+ }>;
283
+ /** 見せたい点を CSS の object-position に(なしは undefined で中央、枠の比が分からない表示の代わり) */
284
+ declare const objectPosition: (hotspot?: Hotspot) => string | undefined;
285
+ /**
286
+ * 枠を覆う表示で、枠に見える範囲(窓)の始まりの位置: 1次元、画像に対する割合で0〜1 - window
287
+ * - 見せたい範囲の中心を窓の中央に寄せ、画像の端を越えるときは端と同じ位置
288
+ * - 範囲が窓に収まる大きさなら、この位置で範囲は必ず窓に入る(範囲が窓より大きいときは範囲の中央を表示)
289
+ * - center なしは中央、CMS のプレビュー(apps/admin の crop.ts)とサイトの表示で共通
290
+ */
291
+ declare function coverStart(center: number | undefined, window: number): number;
292
+ /**
293
+ * 枠を覆う表示(object-fit: cover)の object-position: 見せたい範囲が枠に入る位置
294
+ * - size: 画像の幅と高さ、frame: 枠の幅 / 高さ(なしは見せたい点の百分率で代用)
295
+ * - 帯や余白はなし、枠いっぱいに画像を敷いたまま位置だけを合わせる
296
+ */
297
+ declare function coverPosition(hotspot: Hotspot | undefined, size: Readonly<{
298
+ width: number;
299
+ height: number;
300
+ }>, frame?: number): string | undefined;
301
+ /** 切り抜いた範囲(元の画像の画素、左上と大きさ、1画素以上) */
302
+ type CropRect = Readonly<{
303
+ left: number;
304
+ top: number;
305
+ width: number;
306
+ height: number;
307
+ }>;
308
+ /** 切り抜きの割合から、元画像での画素の範囲 */
309
+ declare function cropRect(width: number, height: number, crop: Crop): CropRect;
310
+ /** 元の画像に対する見せたい点を、切り抜いた後の画像に対する割合に変換(範囲の外は端に寄せる) */
311
+ declare function croppedHotspot(hotspot: Hotspot, crop: Crop): Hotspot;
312
+ /** 暦の日付(`YYYY-MM-DD`) */
313
+ type DateString = `${number}-${number}-${number}`;
314
+ /** 日付だけの値(`YYYY-MM-DD`)か */
315
+ declare const isDateOnly: (value: string) => boolean;
316
+ /** タイムゾーンを固定した日時の変換 */
317
+ type Clock = Readonly<{
318
+ /** 今の日時 */
319
+ nowIso: () => string;
320
+ /** タイムゾーンの日付と時刻(HH:MM、省略は0時)の日時 */
321
+ fromLocal: (date: string, time?: string) => string;
322
+ /** 日付・日時・SQLite の日時を日時に(解釈できない値は null) */
323
+ toDateTime: (value: string) => string | null;
324
+ /** 日時のタイムゾーンの日付(日付だけの値はそのまま) */
325
+ dateOf: (value: string) => string;
326
+ /** 日時のタイムゾーンの時刻(HH:MM) */
327
+ timeOf: (value: string) => string;
328
+ /** タイムゾーンでその日の最後の時点 */
329
+ endOfDay: (date: string) => string;
330
+ /** タイムゾーンの今日の日付(now は試験用の今のミリ秒) */
331
+ today: (now?: number) => string;
332
+ }>;
333
+ /** タイムゾーンの日時の変換を作成 */
334
+ declare function createClock(timeZone: string): Clock;
335
+ export { FieldDef as C, PageEntry as D, PageDef as E, pagePathOf as O, CountDef as S, typeOf as T, Media as _, CropRect as a, ContentModel as b, cropRect as c, stillOf as d, Asset as f, LocalImage as g, Hotspot as h, isDateOnly as i, publicPathOf as k, croppedHotspot as l, Crop as m, DateString as n, coverPosition as o, AssetRef as p, createClock as r, coverStart as s, Clock as t, objectPosition as u, mediaSchema as v, Publishing as w, ContentType as x, ContentFormat as y };
@@ -0,0 +1,131 @@
1
+ import { z } from "zod";
2
+ /** インラインの装飾(strong・em・code・s・mark・sup・sub) */
3
+ type Mark = 'strong' | 'em' | 'code' | 'strike' | 'mark' | 'sup' | 'sub';
4
+ /** リンク(HTML の a の href・title・rel) */
5
+ type Link = Readonly<{
6
+ href: string;
7
+ title?: string;
8
+ rel?: string;
9
+ }>;
10
+ /** 装飾・リンクつきの文字列 */
11
+ type Text = Readonly<{
12
+ text: string;
13
+ marks?: readonly Mark[];
14
+ link?: Link;
15
+ }>;
16
+ /** インラインのコンポーネント(例: 注、属性は文字列だけ、text は中の文字) */
17
+ type InlineComponent = Readonly<{
18
+ component: string;
19
+ props?: Readonly<Record<string, string>>;
20
+ text?: string;
21
+ }>;
22
+ /** インラインの断片(文字列は装飾なし) */
23
+ type Inline = string | Text | InlineComponent;
24
+ /** インラインの断片の列(1つの段落・見出し・セルの内容) */
25
+ type Rich = readonly Inline[];
26
+ /** 見出しの段階 */
27
+ type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
28
+ /** リストの1項目(children は入れ子のリスト) */
29
+ type ListItem = Readonly<{
30
+ content: Rich;
31
+ children?: List;
32
+ }>;
33
+ /** リスト */
34
+ type List = Readonly<{
35
+ type: 'list';
36
+ ordered?: boolean;
37
+ items: readonly ListItem[];
38
+ variant?: string;
39
+ }>;
40
+ /** 標準のブロック */
41
+ type StandardBlock<M> = Readonly<{
42
+ type: 'paragraph';
43
+ content: Rich;
44
+ variant?: string;
45
+ }> | Readonly<{
46
+ type: 'heading';
47
+ level: HeadingLevel;
48
+ content: Rich;
49
+ id?: string;
50
+ variant?: string;
51
+ }> | List | Readonly<{
52
+ type: 'quote';
53
+ content: readonly Rich[];
54
+ cite?: Readonly<{
55
+ label: string;
56
+ href?: string;
57
+ }>;
58
+ variant?: string;
59
+ }> | Readonly<{
60
+ type: 'code';
61
+ code: string;
62
+ lang?: string;
63
+ caption?: string;
64
+ variant?: string;
65
+ }> | Readonly<{
66
+ type: 'image';
67
+ media: M;
68
+ alt?: string;
69
+ caption?: string;
70
+ variant?: string;
71
+ }> | Readonly<{
72
+ type: 'table';
73
+ head?: readonly Rich[];
74
+ rows: readonly (readonly Rich[])[];
75
+ caption?: string;
76
+ variant?: string;
77
+ }> | Readonly<{
78
+ type: 'divider';
79
+ }> | Readonly<{
80
+ type: 'html';
81
+ html: string;
82
+ }>;
83
+ /** カスタムコンポーネントのブロック(props は JSON で表せる値) */
84
+ type ComponentBlock = Readonly<{
85
+ type: 'component';
86
+ name: string;
87
+ props: unknown;
88
+ }>;
89
+ /** 本文のブロック */
90
+ type Block<M> = StandardBlock<M> | ComponentBlock;
91
+ /** 本文 */
92
+ type Body<M> = readonly Block<M>[];
93
+ /** 本文の設定 */
94
+ type BodyProfile = Readonly<{
95
+ headings: readonly HeadingLevel[];
96
+ marks: readonly Mark[];
97
+ /** 装飾の重ね掛けと装飾つきのリンク(既定は可) */
98
+ combineMarks?: boolean;
99
+ /** リンクの rel(なしは rel なしのリンクだけ) */
100
+ linkRels?: readonly string[];
101
+ /** 装飾・リンク・インラインのコンポーネントを使用できない場所 */
102
+ plain?: readonly ('heading' | 'tableHead')[];
103
+ list?: Readonly<{
104
+ ordered: boolean;
105
+ nested: boolean;
106
+ variants?: readonly string[];
107
+ }>;
108
+ quote?: Readonly<{
109
+ multiple: boolean;
110
+ cite: boolean;
111
+ }>;
112
+ /** コード(componentLangs はコンポーネントで書く言語とコンポーネントの名前、例: mermaid は図、その言語のフェンスだけのコードは不可) */
113
+ code?: Readonly<{
114
+ caption: boolean;
115
+ componentLangs?: Readonly<Record<string, string>>;
116
+ }>;
117
+ image?: Readonly<{
118
+ variants?: readonly string[];
119
+ }>;
120
+ table?: Readonly<{
121
+ head: 'required' | 'optional';
122
+ caption: 'required' | 'optional' | 'none';
123
+ }>;
124
+ divider?: true;
125
+ html?: true;
126
+ /** 使用できるコンポーネントの名前(ブロック・インライン) */
127
+ components: readonly string[];
128
+ }>;
129
+ /** 本文の1ブロックの検査(profile は本文の設定、media は画像の参照の検査) */
130
+ declare function blockSchemaOf<M>(profile: BodyProfile, media: z.ZodType<M>): z.ZodType<Block<M>>;
131
+ export { ComponentBlock as a, InlineComponent as c, ListItem as d, Mark as f, Text as h, Body as i, Link as l, StandardBlock as m, BodyProfile as n, HeadingLevel as o, Rich as p, Block as r, Inline as s, blockSchemaOf as t, List as u };
@@ -0,0 +1,2 @@
1
+ import { C as FieldDef, D as PageEntry, E as PageDef, O as pagePathOf, T as typeOf, _ as Media, a as CropRect, b as ContentModel, c as cropRect, d as stillOf, f as Asset, g as LocalImage, h as Hotspot, i as isDateOnly, k as publicPathOf, l as croppedHotspot, m as Crop, n as DateString, o as coverPosition, p as AssetRef, r as createClock, s as coverStart, t as Clock, u as objectPosition, v as mediaSchema, x as ContentType } from "./index-57nirXxn.js";
2
+ export { type Asset, type AssetRef, type Clock, type ContentModel, type ContentType, type Crop, type CropRect, type DateString, type FieldDef, type Hotspot, type LocalImage, type Media, type PageDef, type PageEntry, coverPosition, coverStart, createClock, cropRect, croppedHotspot, isDateOnly, mediaSchema, objectPosition, pagePathOf, publicPathOf, stillOf, typeOf };
package/dist/model.js ADDED
@@ -0,0 +1,31 @@
1
+ import { a as coverPosition, c as croppedHotspot, d as createClock, f as isDateOnly, i as typeOf, l as objectPosition, n as publicPathOf, o as coverStart, s as cropRect, t as pagePathOf, u as stillOf } from "./page-UEKb7_gu.js";
2
+ import { z } from "zod";
3
+ // ../model/src/asset.ts
4
+ const unit = z.number().min(0).max(1);
5
+ /** 見せたい点の検査 */
6
+ const hotspotSchema = z.object({
7
+ x: unit,
8
+ y: unit,
9
+ width: unit.optional(),
10
+ height: unit.optional()
11
+ });
12
+ z.object({
13
+ top: unit,
14
+ bottom: unit,
15
+ left: unit,
16
+ right: unit
17
+ }).refine((c) => c.left + c.right <= .9 && c.top + c.bottom <= .9, "切り抜きが大きすぎる(残りは1割以上)");
18
+ const mediaBase = z.object({
19
+ src: z.string().min(1),
20
+ width: z.number().int().positive(),
21
+ height: z.number().int().positive(),
22
+ alt: z.string(),
23
+ hotspot: hotspotSchema.optional()
24
+ });
25
+ /** 解決後の素材の検査(src は URL、ビルドで読み込んだ画像は JSON に含めないため対象外) */
26
+ const mediaSchema = z.union([mediaBase.extend({ kind: z.literal("image") }), mediaBase.extend({
27
+ kind: z.literal("video"),
28
+ poster: mediaBase
29
+ })]);
30
+ z.object({ asset: z.string().min(1) });
31
+ export { coverPosition, coverStart, createClock, cropRect, croppedHotspot, isDateOnly, mediaSchema, objectPosition, pagePathOf, publicPathOf, stillOf, typeOf };
@@ -0,0 +1,152 @@
1
+ // ../model/src/time.ts
2
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
3
+ const SQLITE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$/;
4
+ const DAY_MS = 864e5;
5
+ /** 日付だけの値(`YYYY-MM-DD`)か */
6
+ const isDateOnly = (value) => DATE_ONLY.test(value);
7
+ /** タイムゾーンの日時の変換を作成 */
8
+ function createClock(timeZone) {
9
+ const format = new Intl.DateTimeFormat("en-CA", {
10
+ timeZone,
11
+ year: "numeric",
12
+ month: "2-digit",
13
+ day: "2-digit",
14
+ hour: "2-digit",
15
+ minute: "2-digit",
16
+ second: "2-digit",
17
+ hourCycle: "h23"
18
+ });
19
+ const partsOf = (ms) => Object.fromEntries(format.formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
20
+ const offsetOf = (ms) => {
21
+ const p = partsOf(ms);
22
+ return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - Math.floor(ms / 1e3) * 1e3;
23
+ };
24
+ const fromLocal = (date, time = "00:00") => {
25
+ const wall = Date.parse(`${date}T${time}:00Z`);
26
+ const first = wall - offsetOf(wall);
27
+ return new Date(wall - offsetOf(first)).toISOString();
28
+ };
29
+ const toDateTime = (value) => {
30
+ const ms = DATE_ONLY.test(value) ? Date.parse(fromLocal(value)) : SQLITE.test(value) ? Date.parse(`${value.replace(" ", "T")}Z`) : Date.parse(value);
31
+ return Number.isNaN(ms) ? null : new Date(ms).toISOString();
32
+ };
33
+ const local = (value) => partsOf(Date.parse(toDateTime(value) ?? value));
34
+ const dateOf = (value) => {
35
+ if (DATE_ONLY.test(value)) return value;
36
+ const p = local(value);
37
+ return `${p.year}-${p.month}-${p.day}`;
38
+ };
39
+ return {
40
+ nowIso: () => (/* @__PURE__ */ new Date()).toISOString(),
41
+ fromLocal,
42
+ toDateTime,
43
+ dateOf,
44
+ timeOf: (value) => {
45
+ const p = local(value);
46
+ return `${p.hour}:${p.minute}`;
47
+ },
48
+ endOfDay: (date) => (/* @__PURE__ */ new Date(Date.parse(fromLocal(new Date(Date.parse(`${date}T00:00:00Z`) + DAY_MS).toISOString().slice(0, 10))) - 1)).toISOString(),
49
+ today: (now = Date.now()) => dateOf(new Date(now).toISOString())
50
+ };
51
+ }
52
+ // ../model/src/image.ts
53
+ /** 静止画として表示する画像(動画は代わりの画像 poster) */
54
+ const stillOf = (media) => media.kind === "video" ? media.poster : media;
55
+ /** 見せたい点を CSS の object-position に(なしは undefined で中央、枠の比が分からない表示の代わり) */
56
+ const objectPosition = (hotspot) => hotspot && `${Math.round(hotspot.x * 1e3) / 10}% ${Math.round(hotspot.y * 1e3) / 10}%`;
57
+ /**
58
+ * 枠を覆う表示で、枠に見える範囲(窓)の始まりの位置: 1次元、画像に対する割合で0〜1 - window
59
+ * - 見せたい範囲の中心を窓の中央に寄せ、画像の端を越えるときは端と同じ位置
60
+ * - 範囲が窓に収まる大きさなら、この位置で範囲は必ず窓に入る(範囲が窓より大きいときは範囲の中央を表示)
61
+ * - center なしは中央、CMS のプレビュー(apps/admin の crop.ts)とサイトの表示で共通
62
+ */
63
+ function coverStart(center, window) {
64
+ const max = Math.max(0, 1 - window);
65
+ return center === void 0 ? max / 2 : Math.min(max, Math.max(0, center - window / 2));
66
+ }
67
+ /**
68
+ * 枠を覆う表示(object-fit: cover)の object-position: 見せたい範囲が枠に入る位置
69
+ * - size: 画像の幅と高さ、frame: 枠の幅 / 高さ(なしは見せたい点の百分率で代用)
70
+ * - 帯や余白はなし、枠いっぱいに画像を敷いたまま位置だけを合わせる
71
+ */
72
+ function coverPosition(hotspot, size, frame) {
73
+ if (!hotspot) return void 0;
74
+ if (!frame) return objectPosition(hotspot);
75
+ const ratio = size.width / size.height;
76
+ const [ww, wh] = ratio > frame ? [frame / ratio, 1] : [1, ratio / frame];
77
+ const percent = (center, window) => window >= 1 ? 50 : Math.round(coverStart(center, window) / (1 - window) * 1e3) / 10;
78
+ return `${percent(hotspot.x, ww)}% ${percent(hotspot.y, wh)}%`;
79
+ }
80
+ /** 切り抜きの割合から、元画像での画素の範囲 */
81
+ function cropRect(width, height, crop) {
82
+ return {
83
+ left: Math.round(width * crop.left),
84
+ top: Math.round(height * crop.top),
85
+ width: Math.max(1, Math.round(width * (1 - crop.left - crop.right))),
86
+ height: Math.max(1, Math.round(height * (1 - crop.top - crop.bottom)))
87
+ };
88
+ }
89
+ const unit = (v) => Math.round(Math.min(1, Math.max(0, v)) * 1e4) / 1e4;
90
+ /** 元の画像に対する見せたい点を、切り抜いた後の画像に対する割合に変換(範囲の外は端に寄せる) */
91
+ function croppedHotspot(hotspot, crop) {
92
+ const w = 1 - crop.left - crop.right;
93
+ const h = 1 - crop.top - crop.bottom;
94
+ return {
95
+ x: unit((hotspot.x - crop.left) / w),
96
+ y: unit((hotspot.y - crop.top) / h),
97
+ ...hotspot.width !== void 0 && hotspot.height !== void 0 ? {
98
+ width: unit(hotspot.width / w),
99
+ height: unit(hotspot.height / h)
100
+ } : {}
101
+ };
102
+ }
103
+ // ../model/src/model.ts
104
+ /** コンテンツスキーマの型(id から、なければ例外: 導入先の設定の誤り) */
105
+ function typeOf(model, id) {
106
+ const type = model.types.find((t) => t.id === id);
107
+ if (!type) throw new Error(`コンテンツスキーマに型 ${id} がない`);
108
+ return type;
109
+ }
110
+ /** 選択肢の項目の選択肢(コアの項目と追加の項目から、まとまりの中の項目は `links.kind` の形、選択肢の項目でなければ空) */
111
+ function optionsOf(type, key) {
112
+ const [head, ...rest] = key.split(".");
113
+ let field = [...type.core ?? [], ...type.fields].find((f) => f.key === head);
114
+ for (const k of rest) field = field?.type === "group" ? field.fields.find((f) => f.key === k) : void 0;
115
+ return field?.type === "select" ? field.options : [];
116
+ }
117
+ // ../model/src/page.ts
118
+ const matches = (when, entry) => !when || when.in.includes(String(entry[when.key]));
119
+ const passes = (rule, entry, now) => {
120
+ const v = entry[rule.key];
121
+ if ("in" in rule) return rule.in.includes(String(v ?? ""));
122
+ if ("notIn" in rule) return !rule.notIn.includes(String(v ?? ""));
123
+ if ("truthy" in rule) return Boolean(v);
124
+ return v === void 0 || v === null || v === "" || String(v) <= now;
125
+ };
126
+ /** 行に当てはまるページの宣言(なければ null) */
127
+ const pageOf = (type, entry) => type.pages?.find((p) => matches(p.when, entry)) ?? null;
128
+ /** 雛形の {key} を行の値に置き換え(値がなければ null) */
129
+ const fill = (path, entry) => {
130
+ let missing = false;
131
+ const filled = path.replace(/\{([A-Za-z0-9_-]+)\}/g, (_, key) => {
132
+ const v = entry[key];
133
+ if (v === void 0 || v === null) missing = true;
134
+ return String(v ?? "");
135
+ });
136
+ return missing ? null : filled;
137
+ };
138
+ /** ページのパス(ページのない型・行は null、公開の状態は見ない) */
139
+ function pagePathOf(type, entry) {
140
+ const page = pageOf(type, entry);
141
+ return page ? fill(page.path, entry) : null;
142
+ }
143
+ /** 行がサイトで公開中のページを持つか(雛形の値は見ない、作成前の入力にも使用、now は今の時刻の ISO 文字列) */
144
+ function hasPublicPage(type, entry, now) {
145
+ const page = pageOf(type, entry);
146
+ return page !== null && (page.public ?? []).every((r) => passes(r, entry, now));
147
+ }
148
+ /** 公開中のページのパス(公開前・予約中・ページのない行は null、now は今の時刻の ISO 文字列) */
149
+ function publicPathOf(type, entry, now) {
150
+ return hasPublicPage(type, entry, now) ? pagePathOf(type, entry) : null;
151
+ }
152
+ export { coverPosition as a, croppedHotspot as c, createClock as d, isDateOnly as f, typeOf as i, objectPosition as l, publicPathOf as n, coverStart as o, optionsOf as r, cropRect as s, pagePathOf as t, stillOf as u };