@unitninety/site-kit 0.0.0-stage → 1.6.2
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 +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z, type ZodType } from 'zod'
|
|
2
|
+
import type { ContainerId, ElementId } from './ids'
|
|
3
|
+
|
|
4
|
+
export type Mode = 'draft' | 'publish'
|
|
5
|
+
export type Severity = 'error' | 'warning'
|
|
6
|
+
export type Id = string | number
|
|
7
|
+
/** A media, page or entry id as stored: a positive integer or a non-empty string. */
|
|
8
|
+
export const Id: ZodType<Id> = z.union([z.int().positive(), z.string().trim().min(1)])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An absolute http(s) URL, or a root-relative path. The character after the leading `/` may be
|
|
12
|
+
* neither `/` nor `\`: a browser reads `//host` and `/\host` alike as another host. Text and Link
|
|
13
|
+
* share it.
|
|
14
|
+
*/
|
|
15
|
+
export const URL_RE = /^(https?:\/\/\S+|\/(?:[^\/\\\s]\S*)?)$/
|
|
16
|
+
/** A phone number: an optional leading `+`, a digit, then at least five more digits, spaces, parentheses or hyphens. */
|
|
17
|
+
export const TEL_RE = /^\+?[0-9][0-9 ()\-]{5,}$/
|
|
18
|
+
|
|
19
|
+
/** An issue relative to one leaf value; the walker prefixes the leaf's path. */
|
|
20
|
+
export type LeafIssue = { path: (string | number)[]; code: string; message: string; severity?: Severity }
|
|
21
|
+
/** An issue at a full path such as `page:4f2/blocks/b1/photo/alt`. */
|
|
22
|
+
export type Issue = { path: string; code: string; message: string; severity: Severity }
|
|
23
|
+
|
|
24
|
+
export type Ref =
|
|
25
|
+
| { kind: 'media'; id: Id }
|
|
26
|
+
| { kind: 'page'; id: Id }
|
|
27
|
+
| { kind: 'entry'; collection: string; id: Id; depth: number }
|
|
28
|
+
| { kind: 'single'; key: string }
|
|
29
|
+
| { kind: 'query'; collection: string; query: unknown }
|
|
30
|
+
| { kind: 'inverse'; collection: string; field: string; id: Id }
|
|
31
|
+
|
|
32
|
+
export type ImageCrop = { x: number; y: number; width: number; height: number }
|
|
33
|
+
export type Rendition = {
|
|
34
|
+
crop: ImageCrop | null
|
|
35
|
+
url: string
|
|
36
|
+
width: number | null
|
|
37
|
+
height: number | null
|
|
38
|
+
blurDataURL?: string
|
|
39
|
+
status: 'ready' | 'processing' | 'error'
|
|
40
|
+
}
|
|
41
|
+
export type MediaRecord = {
|
|
42
|
+
id: Id
|
|
43
|
+
url: string
|
|
44
|
+
mimeType: string
|
|
45
|
+
width: number | null
|
|
46
|
+
height: number | null
|
|
47
|
+
filename: string
|
|
48
|
+
blurDataURL?: string
|
|
49
|
+
/** Renditions the platform has produced for this asset, one per crop. Absent means none yet. */
|
|
50
|
+
renditions?: Rendition[]
|
|
51
|
+
}
|
|
52
|
+
/** A media record as a read value carries it: everything but the platform's renditions. Image, Video and File share it. */
|
|
53
|
+
export type MediaAsset = Omit<MediaRecord, 'renditions'>
|
|
54
|
+
export const MediaAssetSchema = z.object({ id: Id, url: z.string(), mimeType: z.string(), width: z.number().nullable(), height: z.number().nullable(), filename: z.string(), blurDataURL: z.string().optional() })
|
|
55
|
+
export type PageRecord = { id: Id; route: string; title: string }
|
|
56
|
+
/** A stored entry as the platform hands it to `expand`. */
|
|
57
|
+
export type EntryRecord = { id: Id; slug: string; route?: string; fields: Record<string, unknown>; blocks?: unknown[] }
|
|
58
|
+
export type EntryRead = {
|
|
59
|
+
id: Id
|
|
60
|
+
collection: string
|
|
61
|
+
status: 'ready' | 'missing'
|
|
62
|
+
slug?: string
|
|
63
|
+
route?: string
|
|
64
|
+
/** Present when expanded to depth ≥ 1. */
|
|
65
|
+
fields?: Record<string, unknown>
|
|
66
|
+
}
|
|
67
|
+
/** Read schema of an entry reached through a relation, selection or query. */
|
|
68
|
+
export const EntryReadSchema = z.object({ id: Id, collection: z.string(), status: z.enum(['ready', 'missing']), slug: z.string().optional(), route: z.string().optional(), fields: z.record(z.string(), z.unknown()).optional() })
|
|
69
|
+
|
|
70
|
+
/** Everything `expand` may ask for. Built by `ops/expand` from pre-fetched data; the leaf never fetches. */
|
|
71
|
+
export type ExpandCtx = {
|
|
72
|
+
at: string
|
|
73
|
+
draft: boolean
|
|
74
|
+
/** Draft only: the overlay keys (`overlayKey(at)`) of this document's leaves that read in another language than the one asked for. */
|
|
75
|
+
fallbacks?: ReadonlySet<string>
|
|
76
|
+
/** The language a translated frame reads in, read from a draft request's `DRAFT_LOCALE_PARAM`, which internal link hrefs keep as that parameter. */
|
|
77
|
+
draftLocale?: string
|
|
78
|
+
media(id: Id): MediaRecord | undefined
|
|
79
|
+
page(id: Id): PageRecord | undefined
|
|
80
|
+
/** The Single's read value, already expanded by the caller. */
|
|
81
|
+
single(key: string): unknown
|
|
82
|
+
entry(collection: string, id: Id, depth: number): EntryRead
|
|
83
|
+
query(collection: string, query: unknown): { entries: EntryRead[]; total: number }
|
|
84
|
+
inverse(collection: string, field: string, id: Id): EntryRead[]
|
|
85
|
+
}
|
|
86
|
+
export type SeedCtx = {
|
|
87
|
+
importAsset(source: string): Promise<Id>
|
|
88
|
+
resolvePage(key: string): Id
|
|
89
|
+
resolveEntry(collection: string, key: string): Id
|
|
90
|
+
newId(): string
|
|
91
|
+
}
|
|
92
|
+
/** `hasDefault` is true only when the value is absent and a declared default reads in its place. */
|
|
93
|
+
export type FieldMeta = { required: boolean; hasDefault: boolean }
|
|
94
|
+
|
|
95
|
+
export type LeafDecl = {
|
|
96
|
+
element: ElementId
|
|
97
|
+
/** When `false`, the field is never translated. */
|
|
98
|
+
translatable?: boolean
|
|
99
|
+
label?: string
|
|
100
|
+
description?: string
|
|
101
|
+
required?: boolean
|
|
102
|
+
/** Seed shape. */
|
|
103
|
+
default?: unknown
|
|
104
|
+
/** Sub-paths a client may not change, e.g. `['to']` on a link. */
|
|
105
|
+
lock?: string[]
|
|
106
|
+
renamedFrom?: string
|
|
107
|
+
options?: Record<string, unknown>
|
|
108
|
+
}
|
|
109
|
+
export type GroupDecl = { container: 'u90/group'; label?: string; fields: Record<string, FieldDecl>; order?: string[] }
|
|
110
|
+
export type ListDecl = { container: 'u90/list'; label?: string; item: FieldDecl; min?: number; max?: number; default?: unknown[] }
|
|
111
|
+
export type ZoneDecl = { container: 'u90/zone'; label?: string; allow: string[]; default?: string[]; fixed?: boolean }
|
|
112
|
+
export type FieldDecl = LeafDecl | GroupDecl | ListDecl | ZoneDecl
|
|
113
|
+
export const isLeaf = (d: FieldDecl): d is LeafDecl => 'element' in d
|
|
114
|
+
|
|
115
|
+
export type BlockDecl = { name: string; label: string; fields: Record<string, FieldDecl>; entry?: string; order?: string[] }
|
|
116
|
+
export type CollectionDecl = {
|
|
117
|
+
name: string
|
|
118
|
+
label: string
|
|
119
|
+
fields: Record<string, FieldDecl>
|
|
120
|
+
template?: ZoneDecl
|
|
121
|
+
defaultSort?: { field: string; dir: 'asc' | 'desc' } | 'manual'
|
|
122
|
+
order?: string[]
|
|
123
|
+
}
|
|
124
|
+
export type SingleDecl = { key: string; field: FieldDecl }
|
|
125
|
+
export type Schema = {
|
|
126
|
+
blocks: Record<string, BlockDecl>
|
|
127
|
+
collections: Record<string, CollectionDecl>
|
|
128
|
+
singles: Record<string, SingleDecl>
|
|
129
|
+
}
|
|
130
|
+
export type DocumentDecl =
|
|
131
|
+
| { kind: 'page'; blocks: ZoneDecl }
|
|
132
|
+
| { kind: 'layout'; blocks: ZoneDecl }
|
|
133
|
+
| { kind: 'entry'; collection: string }
|
|
134
|
+
| { kind: 'single'; key: string }
|
|
135
|
+
|
|
136
|
+
export const SLOT = 'SLOT'
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What a language overlay holds for this leaf, and how it lands on the stored value. Destinations,
|
|
140
|
+
* media and structure never travel in an overlay: a link's overlay is its label, an image's its alt
|
|
141
|
+
* text and caption. `apply` answers undefined when there is no base value to translate.
|
|
142
|
+
*/
|
|
143
|
+
export type TranslationCodec<O, V, T> = {
|
|
144
|
+
applies(o: O): boolean
|
|
145
|
+
value(o: O): ZodType<T>
|
|
146
|
+
pick(v: V, o: O): T | null
|
|
147
|
+
apply(base: V | undefined, t: T, o: O): V | undefined
|
|
148
|
+
/** Whether a stored value has a part an overlay could land on, such as a video's poster. Absent, every stored value has. */
|
|
149
|
+
present?(v: V, o: O): boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type ElementEntry<O = any, S = unknown, V = unknown, R = unknown> = {
|
|
153
|
+
id: ElementId
|
|
154
|
+
version: number
|
|
155
|
+
label: string
|
|
156
|
+
placement: 'any' | 'collection' | 'content'
|
|
157
|
+
options: ZodType<O>
|
|
158
|
+
seed(o: O): ZodType<S>
|
|
159
|
+
stored(o: O): ZodType<V>
|
|
160
|
+
/**
|
|
161
|
+
* The lenient shape a stored value must have to be read; defaults to `stored(o)`. An element whose
|
|
162
|
+
* options narrow `stored` into an enum, a union or an integer declares one without those rules, so
|
|
163
|
+
* content written under wider options keeps reading while `validate` reports it.
|
|
164
|
+
*/
|
|
165
|
+
shape?(o: O): ZodType
|
|
166
|
+
read(o: O): ZodType<R>
|
|
167
|
+
/** Publish-only completeness. `v` is undefined when the field is absent from storage. */
|
|
168
|
+
complete(v: V | undefined, o: O, field: FieldMeta): LeafIssue[]
|
|
169
|
+
refs(v: V, o: O, at: string): Ref[]
|
|
170
|
+
toStored(seed: S, o: O, ctx: SeedCtx): Promise<V>
|
|
171
|
+
expand(v: V, o: O, ctx: ExpandCtx): R
|
|
172
|
+
/** Read shape of a seed value, for defaults of fields absent from storage. */
|
|
173
|
+
expandSeed(seed: S, o: O, ctx: ExpandCtx): R
|
|
174
|
+
toText(v: V, o: O): string
|
|
175
|
+
translatable(v: V, o: O): { path: (string | number)[]; value: string }[]
|
|
176
|
+
/** What a language overlay holds for this element; null where nothing translates. */
|
|
177
|
+
translation: TranslationCodec<O, V, any> | null
|
|
178
|
+
empty(o: O): V
|
|
179
|
+
upgrade: Record<number, (old: unknown) => unknown>
|
|
180
|
+
}
|
|
181
|
+
export const defineElement = <O, S, V, R>(e: ElementEntry<O, S, V, R>) => e
|
|
182
|
+
|
|
183
|
+
export type Child = { key: string; decl: FieldDecl; value: unknown; set(v: unknown): void }
|
|
184
|
+
export type ContainerEntry<D extends FieldDecl = FieldDecl> = {
|
|
185
|
+
id: ContainerId
|
|
186
|
+
children(decl: D, value: unknown, schema: Schema): Child[]
|
|
187
|
+
/** Structural checks on the container's own value; the walker prefixes the path. */
|
|
188
|
+
enter(decl: D, value: unknown, schema: Schema): LeafIssue[]
|
|
189
|
+
}
|
|
190
|
+
export const defineContainer = <D extends FieldDecl>(c: ContainerEntry<D>) => c
|
|
191
|
+
|
|
192
|
+
export const requiredIssue = (present: boolean, field: FieldMeta): LeafIssue[] =>
|
|
193
|
+
!present && field.required && !field.hasDefault
|
|
194
|
+
? [{ path: [], code: 'required', message: 'A value is required to publish.' }]
|
|
195
|
+
: []
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
|
|
2
|
+
const DIGITS = '0123456789'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `n` characters from `alphabet`, unbiased: a byte at or above the largest multiple of the alphabet's length below 256
|
|
6
|
+
* is redrawn, so every symbol is equally likely. Web Crypto, so the same code runs in Node, browsers and edge runtimes. */
|
|
7
|
+
const draw = (n: number, alphabet: string): string => {
|
|
8
|
+
const limit = 256 - (256 % alphabet.length)
|
|
9
|
+
let out = ''
|
|
10
|
+
while (out.length < n) {
|
|
11
|
+
for (const byte of globalThis.crypto.getRandomValues(new Uint8Array(n * 2))) {
|
|
12
|
+
if (byte >= limit) continue
|
|
13
|
+
out += alphabet[byte % alphabet.length]
|
|
14
|
+
if (out.length === n) break
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A stored `_id`: a list item or a block. 1 to 16 lower-case letters and digits, so ids stored before the 12-character rule still read. */
|
|
21
|
+
export const ID_RE = /^[a-z0-9]{1,16}$/
|
|
22
|
+
/** 12 random characters (about 62 bits): an `_id`, minted by the platform. */
|
|
23
|
+
export const newId = (): string => draw(12, ALPHABET)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A page's, an entry's, a layout's, a Single's or a code draft's id: 16 characters that start with a digit and hold a
|
|
27
|
+
* letter. A declared key starts with a letter, so the two never look alike; and an id is never all digits, so nothing
|
|
28
|
+
* that reads a numeric string as a number can turn one into a number.
|
|
29
|
+
*/
|
|
30
|
+
export const DOCUMENT_ID_RE = /^(?=.*[a-z])[0-9][a-z0-9]{15}$/
|
|
31
|
+
export const isDocumentId = (value: unknown): value is string => typeof value === 'string' && DOCUMENT_ID_RE.test(value)
|
|
32
|
+
export const newDocumentId = (): string => {
|
|
33
|
+
for (;;) {
|
|
34
|
+
const id = draw(1, DIGITS) + draw(15, ALPHABET)
|
|
35
|
+
if (DOCUMENT_ID_RE.test(id)) return id
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const ELEMENT_IDS = [
|
|
40
|
+
'u90/text', 'u90/rich-text', 'u90/image', 'u90/video', 'u90/file', 'u90/link',
|
|
41
|
+
'u90/date', 'u90/number', 'u90/boolean', 'u90/choice',
|
|
42
|
+
'u90/relation', 'u90/selection', 'u90/query', 'u90/single',
|
|
43
|
+
] as const
|
|
44
|
+
export type ElementId = (typeof ELEMENT_IDS)[number]
|
|
45
|
+
|
|
46
|
+
export const CONTAINER_IDS = ['u90/group', 'u90/list', 'u90/zone'] as const
|
|
47
|
+
export type ContainerId = (typeof CONTAINER_IDS)[number]
|
|
48
|
+
|
|
49
|
+
/** `u90/rich-text` → `rich-text`; used for fixture folders and docs anchors. */
|
|
50
|
+
export const shortId = (id: ElementId | ContainerId) => id.slice('u90/'.length)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const MODULE = '@unitninety/site-kit/elements'
|
|
2
|
+
export * from './ids'
|
|
3
|
+
export * from './define'
|
|
4
|
+
export { path, newId, ID_RE, type Path, type DocKind } from './path'
|
|
5
|
+
export { orderedKeys, orderedEntries } from './order'
|
|
6
|
+
export * from './tokens'
|
|
7
|
+
export { validateLeaf, expandLeaf, parseOptions, zodIssues, hasStoredShape } from './leaf'
|
|
8
|
+
export { text, TextOptions, type TextRead, type TextSegment } from './leaves/text'
|
|
9
|
+
export { boolean, BooleanOptions, type BooleanRead } from './leaves/boolean'
|
|
10
|
+
export { number, NumberOptions, type NumberRead } from './leaves/number'
|
|
11
|
+
export { date, DateOptions, type DateRead } from './leaves/date'
|
|
12
|
+
export { choice, ChoiceOptions, type ChoiceRead, type ChoiceReadValue } from './leaves/choice'
|
|
13
|
+
export { link, LinkOptions, LinkTo, linkKind, hrefOf, DRAFT_LOCALE_PARAM, type LinkRead, type LinkKind } from './leaves/link'
|
|
14
|
+
export { single, SingleOptions } from './leaves/single'
|
|
15
|
+
export { richText, RichTextOptions, richTextToPlain, type RichTextRead } from './leaves/rich-text'
|
|
16
|
+
export { image, ImageOptions, ImageConstraints, imageStored, imageSeed, expandImage, imageToStored, type ImageRead, type ImageStored, type ImageSeed, type ImageReadVariant, type ImageAsset } from './leaves/image'
|
|
17
|
+
export { video, VideoOptions, mediaAssetRead, type VideoRead, type MediaAssetRead } from './leaves/video'
|
|
18
|
+
export { file, FileOptions, type FileRead } from './leaves/file'
|
|
19
|
+
export { relation, RelationOptions, isDerived, EntryRef, resolveEntryRef } from './leaves/relation'
|
|
20
|
+
export { selection, SelectionOptions, type EntriesRead } from './leaves/selection'
|
|
21
|
+
export { query, QueryOptions, QueryStored, applyQueryPicks, FILTERABLE, SORTABLE } from './leaves/query'
|
|
22
|
+
export { elements, getElement, CONTRACTS, CURRENT_CONTRACT } from './registry'
|
|
23
|
+
export { validateSchema, FieldDeclSchema, BlockDeclSchema, CollectionDeclSchema, SingleDeclSchema, SchemaSchema } from './declaration'
|
|
24
|
+
export { containers, getContainer } from './registry'
|
|
25
|
+
export { walk, walkDocument, type Visitor, type LeafNode, type ContainerNode } from './walk'
|
|
26
|
+
export { validate, type ValidateOptions } from './ops/validate'
|
|
27
|
+
export { collectRefs } from './ops/refs'
|
|
28
|
+
export { expand, expandField, makeCtx, entryKey, queryKey, inverseKey, type Resolved } from './ops/expand'
|
|
29
|
+
export { prefixedRoute } from './prefix'
|
|
30
|
+
export { seed, seedItem, seedIssues, SeedError, type SeedKnown } from './ops/seed'
|
|
31
|
+
export { textOf } from './ops/text'
|
|
32
|
+
export { translatableParts } from './ops/translatable'
|
|
33
|
+
export { overlayKey, translatableLeaves, applyOverlay, type OverlayValues } from './ops/overlay'
|
|
34
|
+
export { upgrade, UpgradeError, type UpgradeOptions } from './ops/upgrade'
|
|
35
|
+
export { toJSONSchema } from './jsonSchema'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { isLeaf, type FieldDecl, type Schema } from './define'
|
|
3
|
+
import { orderedEntries } from './order'
|
|
4
|
+
import { ID_RE } from './path'
|
|
5
|
+
import { getElement } from './registry'
|
|
6
|
+
|
|
7
|
+
type JsonSchema = Record<string, unknown>
|
|
8
|
+
const id = { type: 'string', pattern: ID_RE.source }
|
|
9
|
+
|
|
10
|
+
/** JSON Schema (draft 2020-12) of a declaration's STORED shape. Used for agent tool schemas and docs. */
|
|
11
|
+
export function toJSONSchema(decl: FieldDecl, schema?: Schema): JsonSchema {
|
|
12
|
+
if (isLeaf(decl)) {
|
|
13
|
+
const entry = getElement(decl.element)
|
|
14
|
+
const { $schema: _s, ...rest } = z.toJSONSchema(entry.stored(entry.options.parse(decl.options ?? {}))) as JsonSchema
|
|
15
|
+
return { ...rest, ...(decl.label ? { title: decl.label } : {}), ...(decl.description ? { description: decl.description } : {}) }
|
|
16
|
+
}
|
|
17
|
+
if (decl.container === 'u90/group')
|
|
18
|
+
return { type: 'object', properties: Object.fromEntries(orderedEntries(decl.fields, decl.order).map(([n, f]) => [n, toJSONSchema(f, schema)])), additionalProperties: false }
|
|
19
|
+
if (decl.container === 'u90/list') {
|
|
20
|
+
// validateSchema rejects a list item that isn't an element or a group, so only those two shapes reach here.
|
|
21
|
+
let item: JsonSchema
|
|
22
|
+
if (isLeaf(decl.item)) {
|
|
23
|
+
item = { type: 'object', properties: { _id: id, _value: toJSONSchema(decl.item, schema) }, required: ['_id', '_value'], additionalProperties: false }
|
|
24
|
+
} else {
|
|
25
|
+
const inner = toJSONSchema(decl.item, schema)
|
|
26
|
+
item = { type: 'object', properties: { _id: id, ...(inner.properties as object) }, required: ['_id'], additionalProperties: false }
|
|
27
|
+
}
|
|
28
|
+
return { type: 'array', items: item, ...(decl.min !== undefined ? { minItems: decl.min } : {}), ...(decl.max !== undefined ? { maxItems: decl.max } : {}) }
|
|
29
|
+
}
|
|
30
|
+
const blocks = decl.allow.map((name) => {
|
|
31
|
+
const block = schema?.blocks[name]
|
|
32
|
+
const fields = block ? (toJSONSchema({ container: 'u90/group', fields: block.fields, order: block.order }, schema).properties as object) : {}
|
|
33
|
+
return { type: 'object', properties: { _id: id, _block: { const: name }, ...fields }, required: ['_id', '_block'], additionalProperties: false }
|
|
34
|
+
})
|
|
35
|
+
return { type: 'array', items: { oneOf: blocks } }
|
|
36
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ZodError } from 'zod'
|
|
2
|
+
import { overlayKey } from './path'
|
|
3
|
+
import type { ElementEntry, ExpandCtx, FieldMeta, LeafDecl, LeafIssue, Mode } from './define'
|
|
4
|
+
|
|
5
|
+
/** zod issues → LeafIssue. A `params.code` set by an element's own check wins over zod's generic code. */
|
|
6
|
+
export const zodIssues = (error: ZodError): LeafIssue[] =>
|
|
7
|
+
error.issues.map((i) => ({
|
|
8
|
+
path: i.path as (string | number)[],
|
|
9
|
+
code: (i as { params?: { code?: string } }).params?.code ?? i.code,
|
|
10
|
+
message: i.message,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
export function parseOptions(entry: ElementEntry, decl: LeafDecl) {
|
|
14
|
+
const r = entry.options.safeParse(decl.options ?? {})
|
|
15
|
+
return r.success
|
|
16
|
+
? ({ ok: true, options: r.data } as const)
|
|
17
|
+
: ({ ok: false, issues: zodIssues(r.error).map((i) => ({ ...i, path: ['options', ...i.path] })) } as const)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* zod codes meaning a value does not have its element's stored shape; any other failure (a length,
|
|
22
|
+
* a pattern, a step, a custom rule) leaves the shape intact, so the value can still be read. The
|
|
23
|
+
* check parses with the element's `shape` where it declares one, else with `stored`, and a failure
|
|
24
|
+
* with one of these codes counts whichever raised it, an element's own refinement included (a
|
|
25
|
+
* query's value-shape rules raise `invalid_type`). Under `stored`, an option that becomes an enum,
|
|
26
|
+
* a union or an integer rule would turn narrowing it into a shape failure and blank old content, so
|
|
27
|
+
* rich text, number and choice declare a `shape` without those rules: their content written before
|
|
28
|
+
* a declaration narrowed keeps rendering while `validate` reports it. `invalid_format` is a shape
|
|
29
|
+
* code too: a value whose format *is* its type (an ISO date, a link's `url`/`email`/`tel`
|
|
30
|
+
* destination) has no usable shape once the format fails. Text's own format rules (`format: 'email'`
|
|
31
|
+
* etc.) are checked as custom issues, not `invalid_format`, so a bad-format text still reads.
|
|
32
|
+
*/
|
|
33
|
+
const SHAPE_CODES = new Set(['invalid_type', 'invalid_union', 'invalid_value', 'invalid_key', 'invalid_element', 'unrecognized_keys', 'invalid_format'])
|
|
34
|
+
/**
|
|
35
|
+
* Whether `value` has the stored shape its element's operations assume. `refs`, `expand`, `toText`
|
|
36
|
+
* and `translatable` only ever see a value that passes, so a malformed stored value is reported by
|
|
37
|
+
* `validate`, reads as the element's empty value, and never throws inside an operation.
|
|
38
|
+
*/
|
|
39
|
+
export const hasStoredShape = (entry: ElementEntry, options: unknown, value: unknown): boolean => {
|
|
40
|
+
const r = (entry.shape ? entry.shape(options) : entry.stored(options)).safeParse(value)
|
|
41
|
+
return r.success || r.error.issues.every((i) => !SHAPE_CODES.has(i.code))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A default stands in only for an absent value (an explicit `null` stays `null`), so only then can it
|
|
46
|
+
* satisfy `required`, and only when the default is itself complete. A blank default (`''`, a rich text
|
|
47
|
+
* with no words, a link with no label) satisfies nothing, so a required field declared with one still
|
|
48
|
+
* asks for a value before publishing.
|
|
49
|
+
*/
|
|
50
|
+
const completeDefault = (entry: ElementEntry, options: unknown, value: unknown): boolean => {
|
|
51
|
+
if (value === undefined || value === null) return false
|
|
52
|
+
const r = entry.stored(options).safeParse(value)
|
|
53
|
+
if (r.success) return entry.complete(r.data, options, { required: true, hasDefault: false }).length === 0
|
|
54
|
+
// A seed shape the stored schema does not take (an image's `{ source }`, an entry `$ref`): only a blank string or list is blank.
|
|
55
|
+
return !(typeof value === 'string' && value.trim() === '') && !(Array.isArray(value) && value.length === 0)
|
|
56
|
+
}
|
|
57
|
+
const meta = (entry: ElementEntry, decl: LeafDecl, options: unknown, absent: boolean): FieldMeta => ({
|
|
58
|
+
required: decl.required === true,
|
|
59
|
+
hasDefault: absent && decl.default !== undefined && completeDefault(entry, options, decl.default),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Strict type check in every mode; `complete` only when publishing.
|
|
64
|
+
* `undefined` means the field is absent from storage (it reads as its default), so only completeness applies.
|
|
65
|
+
*/
|
|
66
|
+
export function validateLeaf(entry: ElementEntry, decl: LeafDecl, value: unknown, mode: Mode): LeafIssue[] {
|
|
67
|
+
const o = parseOptions(entry, decl)
|
|
68
|
+
if (!o.ok) return o.issues
|
|
69
|
+
if (value === undefined) return mode === 'publish' ? entry.complete(undefined, o.options, meta(entry, decl, o.options, true)) : []
|
|
70
|
+
const r = entry.stored(o.options).safeParse(value)
|
|
71
|
+
if (!r.success) return zodIssues(r.error)
|
|
72
|
+
return mode === 'publish' ? entry.complete(r.data, o.options, meta(entry, decl, o.options, false)) : []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read shape of a stored value. An absent value (`undefined`) reads as the declared default or `empty`.
|
|
77
|
+
* A stored `null` is handed to the element's `expand`, which decides what it means: nullable elements
|
|
78
|
+
* return `null`, and `u90/single` / the derived side of `u90/relation` resolve it. A value without the
|
|
79
|
+
* stored shape, or a declaration whose options or default do not parse, reads as the empty value.
|
|
80
|
+
*/
|
|
81
|
+
export function expandLeaf(entry: ElementEntry, decl: LeafDecl, value: unknown, ctx: ExpandCtx): unknown {
|
|
82
|
+
const read = expandLeafValue(entry, decl, value, ctx)
|
|
83
|
+
// In draft, a leaf the reply lists as a fallback is marked beside its `$path`; a published read never is.
|
|
84
|
+
if (ctx.draft && ctx.fallbacks?.size && read && typeof read === 'object' && typeof (read as { $path?: unknown }).$path === 'string' && ctx.fallbacks.has(overlayKey(ctx.at))) return { ...read, $fallback: true }
|
|
85
|
+
return read
|
|
86
|
+
}
|
|
87
|
+
function expandLeafValue(entry: ElementEntry, decl: LeafDecl, value: unknown, ctx: ExpandCtx): unknown {
|
|
88
|
+
const parsed = entry.options.safeParse(decl.options ?? {})
|
|
89
|
+
if (!parsed.success) return null
|
|
90
|
+
const o = parsed.data
|
|
91
|
+
if (value === undefined) {
|
|
92
|
+
if (decl.default === undefined) return entry.expand(entry.empty(o), o, ctx)
|
|
93
|
+
const seeded = entry.seed(o).safeParse(decl.default)
|
|
94
|
+
return seeded.success ? entry.expandSeed(seeded.data, o, ctx) : entry.expand(entry.empty(o), o, ctx)
|
|
95
|
+
}
|
|
96
|
+
if (value !== null && !hasStoredShape(entry, o, value)) return entry.expand(entry.empty(o), o, ctx)
|
|
97
|
+
return entry.expand(value, o, ctx)
|
|
98
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { defineElement, type ExpandCtx } from '../define'
|
|
3
|
+
|
|
4
|
+
export const BooleanOptions = z.strictObject({})
|
|
5
|
+
/** How a boolean reads: its value, and `$path` in draft, so the Canvas can select it (R34). */
|
|
6
|
+
export type BooleanRead = { value: boolean; $path?: string }
|
|
7
|
+
const schema = z.boolean()
|
|
8
|
+
const read = z.object({ value: z.boolean(), $path: z.string().optional() })
|
|
9
|
+
const expand = (v: boolean, _o: unknown, ctx: ExpandCtx): BooleanRead => ({ value: v === true, ...(ctx.draft ? { $path: ctx.at } : {}) })
|
|
10
|
+
|
|
11
|
+
export const boolean = defineElement({
|
|
12
|
+
id: 'u90/boolean',
|
|
13
|
+
version: 1,
|
|
14
|
+
label: 'Boolean',
|
|
15
|
+
placement: 'any',
|
|
16
|
+
options: BooleanOptions,
|
|
17
|
+
seed: () => schema,
|
|
18
|
+
stored: () => schema,
|
|
19
|
+
read: () => read,
|
|
20
|
+
complete: () => [],
|
|
21
|
+
refs: () => [],
|
|
22
|
+
toStored: async (seed) => seed,
|
|
23
|
+
expand,
|
|
24
|
+
expandSeed: expand,
|
|
25
|
+
toText: () => '',
|
|
26
|
+
translatable: () => [],
|
|
27
|
+
translation: null,
|
|
28
|
+
empty: () => false,
|
|
29
|
+
upgrade: {},
|
|
30
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { defineElement, requiredIssue } from '../define'
|
|
3
|
+
|
|
4
|
+
export const ChoiceOptions = z.strictObject({
|
|
5
|
+
values: z.array(z.strictObject({ value: z.string().min(1), label: z.string().min(1) })).min(1),
|
|
6
|
+
multiple: z.boolean().default(false),
|
|
7
|
+
})
|
|
8
|
+
type O = z.infer<typeof ChoiceOptions>
|
|
9
|
+
/** One chosen value as it reads. */
|
|
10
|
+
export type ChoiceRead = { value: string; label: string; $path?: string }
|
|
11
|
+
/** What a choice field reads as: one item, a list of them when `multiple`, or `null` when cleared. */
|
|
12
|
+
export type ChoiceReadValue = ChoiceRead | ChoiceRead[] | null
|
|
13
|
+
|
|
14
|
+
const one = (o: O) => z.enum(o.values.map((v) => v.value) as [string, ...string[]])
|
|
15
|
+
const stored = (o: O) =>
|
|
16
|
+
o.multiple
|
|
17
|
+
? z.array(one(o)).superRefine((vs, ctx) => {
|
|
18
|
+
if (new Set(vs).size !== vs.length)
|
|
19
|
+
ctx.addIssue({ code: 'custom', message: 'Each value may be chosen once.', params: { code: 'duplicate' } })
|
|
20
|
+
})
|
|
21
|
+
: one(o).nullable()
|
|
22
|
+
/** Any string, or a list of them: a value no longer among `values` still reads, labelled by itself. */
|
|
23
|
+
const shape = (o: O) => (o.multiple ? z.array(z.string()) : z.string().nullable())
|
|
24
|
+
const readOne = z.object({ value: z.string(), label: z.string(), $path: z.string().optional() })
|
|
25
|
+
const read = (o: O) => (o.multiple ? z.array(readOne) : readOne.nullable())
|
|
26
|
+
const label = (o: O, v: string) => o.values.find((x) => x.value === v)?.label ?? v
|
|
27
|
+
const expand = (v: string | string[] | null, o: O, ctx: { at: string; draft: boolean }): ChoiceReadValue => {
|
|
28
|
+
const tag = ctx.draft ? { $path: ctx.at } : {}
|
|
29
|
+
if (Array.isArray(v)) return v.map((x) => ({ value: x, label: label(o, x), ...tag }))
|
|
30
|
+
return v === null ? null : { value: v, label: label(o, v), ...tag }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const choice = defineElement({
|
|
34
|
+
id: 'u90/choice',
|
|
35
|
+
version: 1,
|
|
36
|
+
label: 'Choice',
|
|
37
|
+
placement: 'any',
|
|
38
|
+
options: ChoiceOptions,
|
|
39
|
+
seed: stored,
|
|
40
|
+
stored,
|
|
41
|
+
shape,
|
|
42
|
+
read,
|
|
43
|
+
complete: (v, _o, field) => requiredIssue(Array.isArray(v) ? v.length > 0 : typeof v === 'string', field),
|
|
44
|
+
refs: () => [],
|
|
45
|
+
toStored: async (seed) => seed,
|
|
46
|
+
expand,
|
|
47
|
+
expandSeed: expand,
|
|
48
|
+
toText: (v, o) => (Array.isArray(v) ? v.map((x) => label(o, x)).join(', ') : v === null ? '' : label(o, v)),
|
|
49
|
+
translatable: () => [],
|
|
50
|
+
translation: null,
|
|
51
|
+
empty: (o) => (o.multiple ? [] : null),
|
|
52
|
+
upgrade: {},
|
|
53
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { defineElement, requiredIssue } from '../define'
|
|
3
|
+
|
|
4
|
+
export const DateOptions = z.strictObject({ min: z.iso.date().optional(), max: z.iso.date().optional() })
|
|
5
|
+
type O = z.infer<typeof DateOptions>
|
|
6
|
+
export type DateRead = { iso: string; $path?: string } | null
|
|
7
|
+
|
|
8
|
+
const stored = (o: O) =>
|
|
9
|
+
z.iso
|
|
10
|
+
.date()
|
|
11
|
+
.superRefine((v, ctx) => {
|
|
12
|
+
if ((o.min && v < o.min) || (o.max && v > o.max))
|
|
13
|
+
ctx.addIssue({ code: 'custom', message: `Must be between ${o.min ?? 'any'} and ${o.max ?? 'any'}.`, params: { code: 'range' } })
|
|
14
|
+
})
|
|
15
|
+
.nullable()
|
|
16
|
+
const read = z.object({ iso: z.iso.date(), $path: z.string().optional() }).nullable()
|
|
17
|
+
const expand = (v: string | null, _o: O, ctx: { at: string; draft: boolean }): DateRead =>
|
|
18
|
+
v === null ? null : { iso: v, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
19
|
+
|
|
20
|
+
export const date = defineElement({
|
|
21
|
+
id: 'u90/date',
|
|
22
|
+
version: 1,
|
|
23
|
+
label: 'Date',
|
|
24
|
+
placement: 'any',
|
|
25
|
+
options: DateOptions,
|
|
26
|
+
seed: stored,
|
|
27
|
+
stored,
|
|
28
|
+
read: () => read,
|
|
29
|
+
complete: (v, _o, field) => requiredIssue(typeof v === 'string', field),
|
|
30
|
+
refs: () => [],
|
|
31
|
+
toStored: async (seed) => seed,
|
|
32
|
+
expand,
|
|
33
|
+
expandSeed: expand,
|
|
34
|
+
toText: (v) => v ?? '',
|
|
35
|
+
translatable: () => [],
|
|
36
|
+
translation: null,
|
|
37
|
+
empty: () => null,
|
|
38
|
+
upgrade: {},
|
|
39
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Id, MediaAssetSchema, defineElement, requiredIssue, type ExpandCtx, type SeedCtx } from '../define'
|
|
3
|
+
import { mediaAssetRead, type MediaAssetRead } from './video'
|
|
4
|
+
|
|
5
|
+
export const FileOptions = z.strictObject({
|
|
6
|
+
mimeTypes: z.array(z.string().regex(/^[a-z]+\/[a-z0-9.+-]+$/i)).min(1).optional(),
|
|
7
|
+
maxBytes: z.int().positive().optional(),
|
|
8
|
+
})
|
|
9
|
+
type O = z.infer<typeof FileOptions>
|
|
10
|
+
const stored = () => z.strictObject({ asset: Id, label: z.string(), description: z.string().nullable(), downloadName: z.string().nullable() }).nullable()
|
|
11
|
+
type Stored = z.infer<ReturnType<typeof stored>>
|
|
12
|
+
const seed = () =>
|
|
13
|
+
z.strictObject({ asset: z.union([Id, z.strictObject({ source: z.string().trim().min(1) })]), label: z.string().default(''), description: z.string().nullable().default(null), downloadName: z.string().nullable().default(null) }).nullable()
|
|
14
|
+
export type FileRead = { asset: MediaAssetRead | null; status: 'ready' | 'missing'; label: string; description: string | null; downloadName: string | null; $path?: string } | null
|
|
15
|
+
const read = z.object({ asset: MediaAssetSchema.nullable(), status: z.enum(['ready', 'missing']), label: z.string(), description: z.string().nullable(), downloadName: z.string().nullable(), $path: z.string().optional() }).nullable()
|
|
16
|
+
const expand = (v: Stored, _o: O, ctx: ExpandCtx): FileRead =>
|
|
17
|
+
v === null ? null : { ...mediaAssetRead(ctx.media(v.asset)), label: v.label, description: v.description, downloadName: v.downloadName, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
18
|
+
|
|
19
|
+
export const file = defineElement({
|
|
20
|
+
id: 'u90/file',
|
|
21
|
+
version: 1,
|
|
22
|
+
label: 'File',
|
|
23
|
+
placement: 'any',
|
|
24
|
+
options: FileOptions,
|
|
25
|
+
seed,
|
|
26
|
+
stored,
|
|
27
|
+
read: () => read,
|
|
28
|
+
complete: (v, _o, field) => {
|
|
29
|
+
if (v == null) return requiredIssue(false, field)
|
|
30
|
+
return v.label.trim() ? [] : [{ path: ['label'], code: 'blank', message: 'Give the download a label.' }]
|
|
31
|
+
},
|
|
32
|
+
refs: (v) => (v ? [{ kind: 'media', id: v.asset }] : []),
|
|
33
|
+
toStored: async (s, _o, ctx: SeedCtx) => (s === null ? null : { ...s, asset: typeof s.asset === 'object' ? await ctx.importAsset(s.asset.source) : s.asset }),
|
|
34
|
+
expand,
|
|
35
|
+
expandSeed: (s, o, ctx) => {
|
|
36
|
+
if (s === null) return null
|
|
37
|
+
if (typeof s.asset !== 'object') return expand({ ...s, asset: s.asset }, o, ctx)
|
|
38
|
+
const src = s.asset.source
|
|
39
|
+
return { asset: { id: src, url: src, mimeType: '', width: null, height: null, filename: src.split('/').pop() ?? src }, status: 'ready', label: s.label, description: s.description, downloadName: s.downloadName, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
40
|
+
},
|
|
41
|
+
toText: (v) => (v ? [v.label, v.description ?? ''].filter(Boolean).join(' ') : ''),
|
|
42
|
+
translatable: (v) => (v ? [...(v.label ? [{ path: ['label'], value: v.label }] : []), ...(v.description ? [{ path: ['description'], value: v.description }] : [])] : []),
|
|
43
|
+
translation: {
|
|
44
|
+
applies: () => true,
|
|
45
|
+
value: () => z.object({ label: z.string().optional(), description: z.string().optional() }).refine((t) => t.label !== undefined || t.description !== undefined, 'A file translation names its label or description.'),
|
|
46
|
+
pick: (v) => {
|
|
47
|
+
if (!v) return null
|
|
48
|
+
const label = v.label ? v.label : undefined
|
|
49
|
+
const description = v.description ? v.description : undefined
|
|
50
|
+
return label === undefined && description === undefined ? null : { ...(label !== undefined ? { label } : {}), ...(description !== undefined ? { description } : {}) }
|
|
51
|
+
},
|
|
52
|
+
apply: (base, t) => (base ? { ...base, ...(t.label !== undefined ? { label: t.label } : {}), ...(t.description !== undefined ? { description: t.description } : {}) } : undefined),
|
|
53
|
+
},
|
|
54
|
+
empty: () => null,
|
|
55
|
+
upgrade: {},
|
|
56
|
+
})
|