@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,160 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Id, MediaAssetSchema, defineElement, requiredIssue, type ExpandCtx, type ImageCrop, type MediaAsset, type MediaRecord, type Ref, type SeedCtx } from '../define'
|
|
3
|
+
|
|
4
|
+
const pct = z.number().min(0).max(100)
|
|
5
|
+
export const FocalPoint = z.strictObject({ x: pct, y: pct })
|
|
6
|
+
export const Crop = z
|
|
7
|
+
.strictObject({ x: z.number().min(0), y: z.number().min(0), width: z.number().positive(), height: z.number().positive() })
|
|
8
|
+
.refine((c) => c.x + c.width <= 100 && c.y + c.height <= 100, { message: 'Crop must be a finite positive rectangle inside the original image.', params: { code: 'crop' } })
|
|
9
|
+
const insideCrop = (p: { x: number; y: number }, c: ImageCrop) => p.x >= c.x && p.x <= c.x + c.width && p.y >= c.y && p.y <= c.y + c.height
|
|
10
|
+
/**
|
|
11
|
+
* The two cross-field rules shared by every geometry-shaped schema: a stored/seeded `geometry()`, the
|
|
12
|
+
* top-level of `imageStored`/`imageSeed`, and (via nesting) each of their variants. Nesting a schema that
|
|
13
|
+
* calls this from its own `superRefine` auto-prefixes the issue path with that field's key, so a variant
|
|
14
|
+
* built from `geometry(Id)` or `seedGeometry` reports at `variants.<name>.focalPoint` with no extra work here.
|
|
15
|
+
*/
|
|
16
|
+
const crossFieldRules = (v: { focalPoint: { x: number; y: number } | null; crop: ImageCrop | null; alt?: string; decorative?: boolean }, ctx: z.RefinementCtx) => {
|
|
17
|
+
if (v.focalPoint && v.crop && !insideCrop(v.focalPoint, v.crop))
|
|
18
|
+
ctx.addIssue({ code: 'custom', path: ['focalPoint'], message: 'Focal point must lie inside the crop.', params: { code: 'focal-outside-crop' } })
|
|
19
|
+
if (v.decorative && v.alt !== '')
|
|
20
|
+
ctx.addIssue({ code: 'custom', path: ['alt'], message: 'Decorative images must have empty alternative text.', params: { code: 'decorative-alt' } })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const positiveInt = z.int().positive()
|
|
24
|
+
export const ImageConstraints = z.strictObject({
|
|
25
|
+
mimeTypes: z.array(z.string().regex(/^image\/[a-z0-9.+-]+$/i)).min(1).optional(),
|
|
26
|
+
maxBytes: positiveInt.optional(),
|
|
27
|
+
minWidth: positiveInt.optional(),
|
|
28
|
+
minHeight: positiveInt.optional(),
|
|
29
|
+
cropAspectRatios: z.array(z.number().positive()).min(1).optional(),
|
|
30
|
+
})
|
|
31
|
+
const VariantKey = z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/).refine((k) => !['constructor', 'prototype', '__proto__'].includes(k), 'Variant needs a safe name.')
|
|
32
|
+
export const ImageOptions = z.strictObject({
|
|
33
|
+
constraints: ImageConstraints.optional(),
|
|
34
|
+
variants: z.record(VariantKey, z.strictObject({ media: z.string().trim().min(1), constraints: ImageConstraints.optional() })).default({}),
|
|
35
|
+
})
|
|
36
|
+
type O = z.infer<typeof ImageOptions>
|
|
37
|
+
|
|
38
|
+
const geometry = <A extends z.ZodTypeAny>(asset: A) =>
|
|
39
|
+
z.strictObject({ asset, crop: Crop.nullable(), focalPoint: FocalPoint.nullable() }).superRefine((v, ctx) => crossFieldRules(v, ctx))
|
|
40
|
+
const variantsShape = <V extends z.ZodTypeAny>(o: O, variant: V) =>
|
|
41
|
+
z.strictObject(Object.fromEntries(Object.keys(o.variants).map((k) => [k, variant.optional()])))
|
|
42
|
+
|
|
43
|
+
export const imageStored = (o: O) =>
|
|
44
|
+
z
|
|
45
|
+
.strictObject({
|
|
46
|
+
...geometry(Id).shape,
|
|
47
|
+
alt: z.string(),
|
|
48
|
+
decorative: z.boolean(),
|
|
49
|
+
caption: z.string().nullable(),
|
|
50
|
+
variants: variantsShape(o, geometry(Id)),
|
|
51
|
+
})
|
|
52
|
+
.superRefine((v, ctx) => crossFieldRules(v, ctx))
|
|
53
|
+
.nullable()
|
|
54
|
+
export type ImageStored = z.infer<ReturnType<typeof imageStored>>
|
|
55
|
+
|
|
56
|
+
const SeedAsset = z.union([Id, z.strictObject({ source: z.string().trim().min(1) })])
|
|
57
|
+
const seedGeometry = z
|
|
58
|
+
.strictObject({ asset: SeedAsset, crop: Crop.nullable().default(null), focalPoint: FocalPoint.nullable().default(null) })
|
|
59
|
+
.superRefine((v, ctx) => crossFieldRules(v, ctx))
|
|
60
|
+
export const imageSeed = (o: O) =>
|
|
61
|
+
z
|
|
62
|
+
.strictObject({
|
|
63
|
+
...seedGeometry.shape,
|
|
64
|
+
alt: z.string().default(''),
|
|
65
|
+
decorative: z.boolean().default(false),
|
|
66
|
+
caption: z.string().nullable().default(null).transform((c) => (c === '' ? null : c)),
|
|
67
|
+
variants: variantsShape(o, seedGeometry).default({}),
|
|
68
|
+
})
|
|
69
|
+
.superRefine((v, ctx) => crossFieldRules(v, ctx))
|
|
70
|
+
.nullable()
|
|
71
|
+
export type ImageSeed = z.infer<ReturnType<typeof imageSeed>>
|
|
72
|
+
|
|
73
|
+
export type ImageAsset = MediaAsset
|
|
74
|
+
export type ImageReadVariant = { asset: ImageAsset | null; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null; rendition: { url: string; width: number | null; height: number | null; blurDataURL?: string } | null; status: 'ready' | 'missing' | 'processing' | 'error' }
|
|
75
|
+
export type ImageRead = (ImageReadVariant & { alt: string; decorative: boolean; caption: string | null; variants: Record<string, ImageReadVariant>; $path?: string }) | null
|
|
76
|
+
const renditionRead = z.object({ url: z.string(), width: z.number().nullable(), height: z.number().nullable(), blurDataURL: z.string().optional() })
|
|
77
|
+
const variantRead = z.object({ asset: MediaAssetSchema.nullable(), crop: Crop.nullable(), focalPoint: FocalPoint.nullable(), rendition: renditionRead.nullable(), status: z.enum(['ready', 'missing', 'processing', 'error']) })
|
|
78
|
+
const imageRead = z.object({ ...variantRead.shape, alt: z.string(), decorative: z.boolean(), caption: z.string().nullable(), variants: z.record(z.string(), variantRead), $path: z.string().optional() }).nullable()
|
|
79
|
+
|
|
80
|
+
const sameCrop = (a: ImageCrop | null, b: ImageCrop | null) => (a === null && b === null) || (!!a && !!b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height)
|
|
81
|
+
const pick = (m: MediaRecord): ImageAsset => { const { renditions: _r, ...asset } = m; return asset }
|
|
82
|
+
const readVariant = (v: { asset: Id; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }, ctx: ExpandCtx): ImageReadVariant => {
|
|
83
|
+
const m = ctx.media(v.asset)
|
|
84
|
+
if (!m) return { asset: null, crop: v.crop, focalPoint: v.focalPoint, rendition: null, status: 'missing' }
|
|
85
|
+
const r = m.renditions?.find((x) => sameCrop(x.crop, v.crop))
|
|
86
|
+
if (r) return { asset: pick(m), crop: v.crop, focalPoint: v.focalPoint, rendition: r.status === 'ready' ? { url: r.url, width: r.width, height: r.height, ...(r.blurDataURL ? { blurDataURL: r.blurDataURL } : {}) } : null, status: r.status }
|
|
87
|
+
if (v.crop === null) return { asset: pick(m), crop: null, focalPoint: v.focalPoint, rendition: { url: m.url, width: m.width, height: m.height, ...(m.blurDataURL ? { blurDataURL: m.blurDataURL } : {}) }, status: 'ready' }
|
|
88
|
+
return { asset: pick(m), crop: v.crop, focalPoint: v.focalPoint, rendition: null, status: 'processing' }
|
|
89
|
+
}
|
|
90
|
+
export const expandImage = (v: ImageStored, _o: O, ctx: ExpandCtx): ImageRead =>
|
|
91
|
+
v === null
|
|
92
|
+
? null
|
|
93
|
+
: {
|
|
94
|
+
...readVariant(v, ctx),
|
|
95
|
+
alt: v.alt,
|
|
96
|
+
decorative: v.decorative,
|
|
97
|
+
caption: v.caption,
|
|
98
|
+
variants: Object.fromEntries(Object.entries(v.variants).filter(([, x]) => x).map(([k, x]) => [k, readVariant(x!, ctx)])),
|
|
99
|
+
...(ctx.draft ? { $path: ctx.at } : {}),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A seed's `{ source }` reads as an asset served from that path, until seeding imports it. */
|
|
103
|
+
const sourceCtx = (ctx: ExpandCtx, sources: Map<string, string>): ExpandCtx => ({
|
|
104
|
+
...ctx,
|
|
105
|
+
media: (id) => {
|
|
106
|
+
const source = sources.get(String(id))
|
|
107
|
+
return source ? { id, url: source, mimeType: '', width: null, height: null, filename: source.split('/').pop() ?? source } : ctx.media(id)
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
const toSeedStored = (s: NonNullable<ImageSeed>, sources: Map<string, string>): NonNullable<ImageStored> => {
|
|
111
|
+
const lower = (g: { asset: Id | { source: string }; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }) => {
|
|
112
|
+
if (typeof g.asset === 'object') { const key = `seed:${g.asset.source}`; sources.set(key, g.asset.source); return { ...g, asset: key } }
|
|
113
|
+
return g as { asset: Id; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }
|
|
114
|
+
}
|
|
115
|
+
return { ...lower(s), alt: s.alt, decorative: s.decorative, caption: s.caption, variants: Object.fromEntries(Object.entries(s.variants).filter(([, g]) => g).map(([k, g]) => [k, lower(g!)])) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const imageToStored = async (s: ImageSeed, o: O, ctx: SeedCtx): Promise<ImageStored> => {
|
|
119
|
+
if (s === null) return null
|
|
120
|
+
const resolve = async (g: { asset: Id | { source: string }; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }) => ({ ...g, asset: typeof g.asset === 'object' ? await ctx.importAsset(g.asset.source) : g.asset })
|
|
121
|
+
const variants: Record<string, { asset: Id; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }> = {}
|
|
122
|
+
for (const [k, g] of Object.entries(s.variants)) if (g) variants[k] = await resolve(g)
|
|
123
|
+
return imageStored(o).parse({ ...(await resolve(s)), alt: s.alt, decorative: s.decorative, caption: s.caption, variants })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const mediaRefs = (v: ImageStored): Ref[] => (v ? [{ kind: 'media', id: v.asset }, ...Object.values(v.variants).filter((x) => x).map((x) => ({ kind: 'media' as const, id: x!.asset }))] : [])
|
|
127
|
+
|
|
128
|
+
export const image = defineElement({
|
|
129
|
+
id: 'u90/image',
|
|
130
|
+
version: 1,
|
|
131
|
+
label: 'Image',
|
|
132
|
+
placement: 'any',
|
|
133
|
+
options: ImageOptions,
|
|
134
|
+
seed: imageSeed,
|
|
135
|
+
stored: imageStored,
|
|
136
|
+
read: () => imageRead,
|
|
137
|
+
complete: (v, _o, field) => {
|
|
138
|
+
if (v == null) return requiredIssue(false, field)
|
|
139
|
+
return !v.decorative && !v.alt.trim() ? [{ path: ['alt'], code: 'alt', message: 'Meaningful images need alternative text for publication.' }] : []
|
|
140
|
+
},
|
|
141
|
+
refs: mediaRefs,
|
|
142
|
+
toStored: imageToStored,
|
|
143
|
+
expand: expandImage,
|
|
144
|
+
expandSeed: (s, o, ctx) => { if (s === null) return null; const sources = new Map<string, string>(); return expandImage(toSeedStored(s, sources), o, sourceCtx(ctx, sources)) },
|
|
145
|
+
toText: (v) => (v ? [v.decorative ? '' : v.alt, v.caption ?? ''].filter(Boolean).join(' ') : ''),
|
|
146
|
+
translatable: (v) => (v ? [...(!v.decorative && v.alt ? [{ path: ['alt'], value: v.alt }] : []), ...(v.caption ? [{ path: ['caption'], value: v.caption }] : [])] : []),
|
|
147
|
+
translation: {
|
|
148
|
+
applies: () => true,
|
|
149
|
+
value: () => z.object({ alt: z.string().min(1).optional(), caption: z.string().optional() }).refine((t) => t.alt !== undefined || t.caption !== undefined, 'An image translation names its alt text or caption.'),
|
|
150
|
+
pick: (v) => {
|
|
151
|
+
if (!v) return null
|
|
152
|
+
const alt = !v.decorative && v.alt ? v.alt : undefined
|
|
153
|
+
const caption = v.caption ? v.caption : undefined
|
|
154
|
+
return alt === undefined && caption === undefined ? null : { ...(alt !== undefined ? { alt } : {}), ...(caption !== undefined ? { caption } : {}) }
|
|
155
|
+
},
|
|
156
|
+
apply: (base, t) => (base ? { ...base, ...(t.alt !== undefined && !base.decorative ? { alt: t.alt } : {}), ...(t.caption !== undefined ? { caption: t.caption } : {}) } : undefined),
|
|
157
|
+
},
|
|
158
|
+
empty: () => null,
|
|
159
|
+
upgrade: {},
|
|
160
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Id, TEL_RE, URL_RE, defineElement, requiredIssue, type ExpandCtx, type SeedCtx } from '../define'
|
|
3
|
+
import { isDocumentId } from '../ids'
|
|
4
|
+
|
|
5
|
+
const ANCHOR = z.string().regex(/^[A-Za-z][\w-]*$/)
|
|
6
|
+
const KINDS = ['page', 'entry', 'url', 'email', 'tel'] as const
|
|
7
|
+
export type LinkKind = (typeof KINDS)[number]
|
|
8
|
+
|
|
9
|
+
export const LinkTo = z.union([
|
|
10
|
+
z.strictObject({ page: Id, anchor: ANCHOR.optional() }),
|
|
11
|
+
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
12
|
+
z.strictObject({ url: z.string().regex(URL_RE, 'Expected an absolute URL or a root-relative path.') }),
|
|
13
|
+
z.strictObject({ email: z.email() }),
|
|
14
|
+
z.strictObject({ tel: z.string().regex(TEL_RE, 'Expected a phone number.') }),
|
|
15
|
+
])
|
|
16
|
+
export type LinkTo = z.infer<typeof LinkTo>
|
|
17
|
+
/** Seeds may name pages and entries by key instead of id. */
|
|
18
|
+
const LinkToSeed = z.union([
|
|
19
|
+
z.strictObject({ page: z.union([Id, z.string().min(1)]), anchor: ANCHOR.optional() }),
|
|
20
|
+
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), key: z.string().min(1) }) }),
|
|
21
|
+
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
22
|
+
z.strictObject({ url: z.string().regex(URL_RE) }),
|
|
23
|
+
z.strictObject({ email: z.email() }),
|
|
24
|
+
z.strictObject({ tel: z.string().regex(TEL_RE) }),
|
|
25
|
+
])
|
|
26
|
+
type SeedTo = z.infer<typeof LinkToSeed>
|
|
27
|
+
|
|
28
|
+
export const LinkOptions = z.strictObject({ allow: z.array(z.enum(KINDS)).min(1).default([...KINDS]) })
|
|
29
|
+
type O = z.infer<typeof LinkOptions>
|
|
30
|
+
export const linkKind = (to: LinkTo): LinkKind => KINDS.find((k) => k in to)!
|
|
31
|
+
|
|
32
|
+
const stored = (o: O) =>
|
|
33
|
+
z
|
|
34
|
+
.strictObject({
|
|
35
|
+
label: z.string(),
|
|
36
|
+
to: LinkTo.nullable(),
|
|
37
|
+
newTab: z.boolean(),
|
|
38
|
+
})
|
|
39
|
+
.superRefine((v, ctx) => {
|
|
40
|
+
if (v.to && !o.allow.includes(linkKind(v.to)))
|
|
41
|
+
ctx.addIssue({ code: 'custom', path: ['to'], message: `This link may only point at: ${o.allow.join(', ')}.`, params: { code: 'not-allowed' } })
|
|
42
|
+
})
|
|
43
|
+
.nullable()
|
|
44
|
+
type Stored = z.infer<ReturnType<typeof stored>>
|
|
45
|
+
const seed = () =>
|
|
46
|
+
z.strictObject({ label: z.string().default(''), to: LinkToSeed.nullable().default(null), newTab: z.boolean().default(false) }).nullable()
|
|
47
|
+
type SeedValue = z.infer<ReturnType<typeof seed>>
|
|
48
|
+
|
|
49
|
+
export type LinkRead = { label: string; href: string | null; newTab: boolean; to: LinkTo | null; status: 'ready' | 'missing'; $path?: string } | null
|
|
50
|
+
const read = z
|
|
51
|
+
.object({ label: z.string(), href: z.string().nullable(), newTab: z.boolean(), to: LinkTo.nullable(), status: z.enum(['ready', 'missing']), $path: z.string().optional() })
|
|
52
|
+
.nullable()
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The site query parameter that carries a language in draft (the Canvas's translated frames). Outside draft a site ignores it.
|
|
56
|
+
* An internal href keeps it, so following a link inside a translated frame stays in that language.
|
|
57
|
+
*/
|
|
58
|
+
export const DRAFT_LOCALE_PARAM = 'u90-locale'
|
|
59
|
+
/** An href on this site: root-relative, and not `//host`. The same rule as the `Link` component's `isInternal`. */
|
|
60
|
+
const internal = (href: string) => href.startsWith('/') && !href.startsWith('//')
|
|
61
|
+
/** `href` with `DRAFT_LOCALE_PARAM=<locale>` in its query, after any query it already has and before its fragment. A value it already carries is replaced. */
|
|
62
|
+
export const withDraftLocale = (href: string, locale: string): string => {
|
|
63
|
+
const hashAt = href.indexOf('#')
|
|
64
|
+
const hash = hashAt < 0 ? '' : href.slice(hashAt)
|
|
65
|
+
const rest = hashAt < 0 ? href : href.slice(0, hashAt)
|
|
66
|
+
const queryAt = rest.indexOf('?')
|
|
67
|
+
const base = queryAt < 0 ? rest : rest.slice(0, queryAt)
|
|
68
|
+
const pairs = queryAt < 0 ? [] : rest.slice(queryAt + 1).split('&').filter((p) => p !== '' && p.split('=')[0] !== DRAFT_LOCALE_PARAM)
|
|
69
|
+
return `${base}?${[...pairs, `${DRAFT_LOCALE_PARAM}=${encodeURIComponent(locale)}`].join('&')}${hash}`
|
|
70
|
+
}
|
|
71
|
+
/** In draft with a `draftLocale`, an internal href keeps the language; every other href, and every href outside draft, is unchanged. */
|
|
72
|
+
export const hrefOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
73
|
+
const r = targetOf(to, ctx)
|
|
74
|
+
return ctx.draftLocale && r.href && internal(r.href) ? { ...r, href: withDraftLocale(r.href, ctx.draftLocale) } : r
|
|
75
|
+
}
|
|
76
|
+
const targetOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
77
|
+
if (!to) return { href: null, status: 'ready' }
|
|
78
|
+
if ('page' in to) {
|
|
79
|
+
const p = ctx.page(to.page)
|
|
80
|
+
return p ? { href: `${p.route}${to.anchor ? `#${to.anchor}` : ''}`, status: 'ready' } : { href: null, status: 'missing' }
|
|
81
|
+
}
|
|
82
|
+
if ('entry' in to) {
|
|
83
|
+
const e = ctx.entry(to.entry.collection, to.entry.id, 0)
|
|
84
|
+
return e.status === 'ready' && e.route ? { href: e.route, status: 'ready' } : { href: null, status: 'missing' }
|
|
85
|
+
}
|
|
86
|
+
if ('url' in to) return { href: to.url, status: 'ready' }
|
|
87
|
+
if ('email' in to) return { href: `mailto:${to.email}`, status: 'ready' }
|
|
88
|
+
return { href: `tel:${to.tel.replace(/[^+\d]/g, '')}`, status: 'ready' }
|
|
89
|
+
}
|
|
90
|
+
const expand = (v: Stored, _o: O, ctx: ExpandCtx): LinkRead =>
|
|
91
|
+
v === null ? null : { label: v.label, ...hrefOf(v.to, ctx), newTab: v.newTab, to: v.to, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A seed `to` that names a page or an entry by key rather than id. `ExpandCtx` deliberately has no key lookup
|
|
95
|
+
* (seeding is what resolves keys), so this can never be turned into an href — page and entry keys are treated
|
|
96
|
+
* the same way here, both unresolvable until `toStored` runs.
|
|
97
|
+
*/
|
|
98
|
+
const isUnresolvedSeedRef = (to: SeedTo | null): boolean =>
|
|
99
|
+
!!to && (('page' in to && typeof to.page === 'string' && !isDocumentId(to.page)) || ('entry' in to && 'key' in to.entry))
|
|
100
|
+
|
|
101
|
+
const expandSeed = (s: SeedValue, o: O, ctx: ExpandCtx): LinkRead => {
|
|
102
|
+
if (s === null) return null
|
|
103
|
+
if (isUnresolvedSeedRef(s.to)) return { label: s.label, href: null, newTab: s.newTab, to: null, status: 'missing', ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
104
|
+
return expand({ label: s.label, to: s.to as LinkTo | null, newTab: s.newTab }, o, ctx)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const link = defineElement({
|
|
108
|
+
id: 'u90/link',
|
|
109
|
+
version: 1,
|
|
110
|
+
label: 'Link',
|
|
111
|
+
placement: 'any',
|
|
112
|
+
options: LinkOptions,
|
|
113
|
+
seed,
|
|
114
|
+
stored,
|
|
115
|
+
read: () => read,
|
|
116
|
+
complete: (v, _o, field) => {
|
|
117
|
+
if (v == null) return requiredIssue(false, field)
|
|
118
|
+
const issues = []
|
|
119
|
+
if (!v.to && field.required) issues.push({ path: ['to'], code: 'required', message: 'Choose where the link goes.' })
|
|
120
|
+
if (!v.label.trim()) issues.push({ path: ['label'], code: 'blank', message: 'The link needs words.' })
|
|
121
|
+
return issues
|
|
122
|
+
},
|
|
123
|
+
refs: (v) => {
|
|
124
|
+
if (!v?.to) return []
|
|
125
|
+
if ('page' in v.to) return [{ kind: 'page', id: v.to.page }]
|
|
126
|
+
if ('entry' in v.to) return [{ kind: 'entry', collection: v.to.entry.collection, id: v.to.entry.id, depth: 0 }]
|
|
127
|
+
return []
|
|
128
|
+
},
|
|
129
|
+
toStored: async (s, _o, ctx: SeedCtx) => {
|
|
130
|
+
if (s === null) return null
|
|
131
|
+
let to: LinkTo | null = null
|
|
132
|
+
if (s.to && 'page' in s.to) to = { page: typeof s.to.page === 'string' && !isDocumentId(s.to.page) ? ctx.resolvePage(s.to.page) : s.to.page, ...(s.to.anchor ? { anchor: s.to.anchor } : {}) }
|
|
133
|
+
else if (s.to && 'entry' in s.to) to = { entry: { collection: s.to.entry.collection, id: 'key' in s.to.entry ? ctx.resolveEntry(s.to.entry.collection, s.to.entry.key) : s.to.entry.id } }
|
|
134
|
+
else to = s.to
|
|
135
|
+
return { label: s.label, to, newTab: s.newTab }
|
|
136
|
+
},
|
|
137
|
+
expand,
|
|
138
|
+
expandSeed,
|
|
139
|
+
toText: (v) => v?.label ?? '',
|
|
140
|
+
translatable: (v) => (v && v.label ? [{ path: ['label'], value: v.label }] : []),
|
|
141
|
+
translation: {
|
|
142
|
+
applies: () => true,
|
|
143
|
+
value: () => z.object({ label: z.string().min(1) }),
|
|
144
|
+
pick: (v) => (v?.label ? { label: v.label } : null),
|
|
145
|
+
apply: (base, t) => (base ? { ...base, label: t.label } : undefined),
|
|
146
|
+
},
|
|
147
|
+
empty: () => null,
|
|
148
|
+
upgrade: {},
|
|
149
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { defineElement, requiredIssue } from '../define'
|
|
3
|
+
|
|
4
|
+
export const NumberOptions = z.strictObject({
|
|
5
|
+
integer: z.boolean().default(false),
|
|
6
|
+
min: z.number().optional(),
|
|
7
|
+
max: z.number().optional(),
|
|
8
|
+
step: z.number().positive().optional(),
|
|
9
|
+
})
|
|
10
|
+
type O = z.infer<typeof NumberOptions>
|
|
11
|
+
export type NumberRead = { value: number; $path?: string } | null
|
|
12
|
+
|
|
13
|
+
const stored = (o: O) => {
|
|
14
|
+
let s = o.integer ? z.int() : z.number()
|
|
15
|
+
if (o.min !== undefined) s = s.min(o.min)
|
|
16
|
+
if (o.max !== undefined) s = s.max(o.max)
|
|
17
|
+
return s
|
|
18
|
+
.superRefine((v, ctx) => {
|
|
19
|
+
if (o.step === undefined) return
|
|
20
|
+
const offset = (v - (o.min ?? 0)) / o.step
|
|
21
|
+
if (Math.abs(offset - Math.round(offset)) > 1e-9)
|
|
22
|
+
ctx.addIssue({ code: 'custom', message: `Must be a multiple of ${o.step}${o.min !== undefined ? ` from ${o.min}` : ''}.`, params: { code: 'step' } })
|
|
23
|
+
})
|
|
24
|
+
.nullable()
|
|
25
|
+
}
|
|
26
|
+
/** Any number: `integer`, `min`, `max` and `step` are rules, so a value that breaks one still reads. */
|
|
27
|
+
const SHAPE = z.number().nullable()
|
|
28
|
+
const read = z.object({ value: z.number(), $path: z.string().optional() }).nullable()
|
|
29
|
+
const expand = (v: number | null, _o: O, ctx: { at: string; draft: boolean }): NumberRead =>
|
|
30
|
+
v === null ? null : { value: v, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
31
|
+
|
|
32
|
+
export const number = defineElement({
|
|
33
|
+
id: 'u90/number',
|
|
34
|
+
version: 1,
|
|
35
|
+
label: 'Number',
|
|
36
|
+
placement: 'any',
|
|
37
|
+
options: NumberOptions,
|
|
38
|
+
seed: stored,
|
|
39
|
+
stored,
|
|
40
|
+
shape: () => SHAPE,
|
|
41
|
+
read: () => read,
|
|
42
|
+
complete: (v, _o, field) => requiredIssue(typeof v === 'number', field),
|
|
43
|
+
refs: () => [],
|
|
44
|
+
toStored: async (seed) => seed,
|
|
45
|
+
expand,
|
|
46
|
+
expandSeed: expand,
|
|
47
|
+
toText: (v) => (v === null ? '' : String(v)),
|
|
48
|
+
translatable: () => [],
|
|
49
|
+
translation: null,
|
|
50
|
+
empty: () => null,
|
|
51
|
+
upgrade: {},
|
|
52
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Id, defineElement, type ExpandCtx, type Ref } from '../define'
|
|
3
|
+
import { ELEMENT_IDS, type ElementId } from '../ids'
|
|
4
|
+
import { EntriesReadSchema, type EntriesRead } from './selection'
|
|
5
|
+
|
|
6
|
+
export const FILTERABLE: readonly ElementId[] = ['u90/choice', 'u90/boolean', 'u90/relation', 'u90/date', 'u90/number']
|
|
7
|
+
export const SORTABLE: readonly ElementId[] = ['u90/text', 'u90/date', 'u90/number']
|
|
8
|
+
const OPS = ['eq', 'neq', 'in', 'not-in', 'is-set'] as const
|
|
9
|
+
|
|
10
|
+
export const QueryOptions = z.strictObject({
|
|
11
|
+
to: z.string().min(1),
|
|
12
|
+
/** field → element of the target collection; filled in by validateSchema from the CollectionDecl. */
|
|
13
|
+
targetFields: z.record(z.string(), z.enum(ELEMENT_IDS)).optional(),
|
|
14
|
+
})
|
|
15
|
+
type O = z.infer<typeof QueryOptions>
|
|
16
|
+
const Scalar = z.union([z.string(), z.number(), z.boolean()])
|
|
17
|
+
export const QueryStored = z.strictObject({
|
|
18
|
+
where: z.array(z.strictObject({ field: z.string().min(1), op: z.enum(OPS), value: z.union([Scalar, z.array(Scalar)]).optional() })).optional(),
|
|
19
|
+
sort: z.union([z.strictObject({ field: z.string().min(1), dir: z.enum(['asc', 'desc']) }), z.literal('manual')]).optional(),
|
|
20
|
+
limit: z.int().min(1).max(100).optional(),
|
|
21
|
+
/** Entries the rule returns that this placement leaves out. */
|
|
22
|
+
exclude: z.array(Id).optional(),
|
|
23
|
+
/** Entries this placement adds although the rule does not return them. */
|
|
24
|
+
pin: z.array(Id).optional(),
|
|
25
|
+
/** A hand-set order: these entries first, in this order, then the rest in the rule's own order. */
|
|
26
|
+
order: z.array(Id).optional(),
|
|
27
|
+
})
|
|
28
|
+
export type QueryStored = z.infer<typeof QueryStored>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A query's own picks applied to the ids its rule returned, in the rule's order: excluded entries
|
|
32
|
+
* leave, `limit` cuts what remains of the rule's own results, pinned entries not among those kept
|
|
33
|
+
* join at the end (an id both excluded and pinned stays out), and a hand-set `order` puts its
|
|
34
|
+
* entries first. So a pin survives the limit. `total` counts the rule's matches after exclusions
|
|
35
|
+
* plus the pins not among them, before `limit`. The platform and the fake both call this, so a
|
|
36
|
+
* placement reads the same everywhere. `exists` says whether a pinned id names an entry the read
|
|
37
|
+
* may show.
|
|
38
|
+
*/
|
|
39
|
+
export const applyQueryPicks = (ids: Id[], q: QueryStored, exists: (id: Id) => boolean): { ids: Id[]; total: number } => {
|
|
40
|
+
const excluded = new Set((q.exclude ?? []).map(String))
|
|
41
|
+
const matched = ids.filter((id) => !excluded.has(String(id)))
|
|
42
|
+
const matches = new Set(matched.map(String))
|
|
43
|
+
const out = q.limit ? matched.slice(0, q.limit) : [...matched]
|
|
44
|
+
const present = new Map(out.map((id) => [String(id), id]))
|
|
45
|
+
let pinsBeyond = 0
|
|
46
|
+
for (const id of q.pin ?? [])
|
|
47
|
+
if (!present.has(String(id)) && !excluded.has(String(id)) && exists(id)) {
|
|
48
|
+
out.push(id)
|
|
49
|
+
present.set(String(id), id)
|
|
50
|
+
if (!matches.has(String(id))) pinsBeyond++
|
|
51
|
+
}
|
|
52
|
+
const total = matched.length + pinsBeyond
|
|
53
|
+
if (!q.order?.length) return { ids: out, total }
|
|
54
|
+
const first = q.order.filter((id) => present.has(String(id))).map((id) => present.get(String(id))!)
|
|
55
|
+
const leading = new Set(first.map(String))
|
|
56
|
+
return { ids: [...first, ...out.filter((id) => !leading.has(String(id)))], total }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const stored = (o: O) =>
|
|
60
|
+
QueryStored.superRefine((q, ctx) => {
|
|
61
|
+
const fields = o.targetFields
|
|
62
|
+
q.where?.forEach((w, i) => {
|
|
63
|
+
const el = fields?.[w.field]
|
|
64
|
+
if (fields && !el) return ctx.addIssue({ code: 'custom', path: ['where', i, 'field'], message: `The target has no field "${w.field}".`, params: { code: 'unknown-field' } })
|
|
65
|
+
if (el && !FILTERABLE.includes(el)) return ctx.addIssue({ code: 'custom', path: ['where', i, 'field'], message: 'Only choice, boolean, relation, date and number fields can be filtered.', params: { code: 'not-filterable' } })
|
|
66
|
+
if ((w.op === 'in' || w.op === 'not-in') && !Array.isArray(w.value)) ctx.addIssue({ code: 'invalid_type', path: ['where', i, 'value'], message: 'in and not-in take a list of values.', expected: 'array', input: w.value } as never)
|
|
67
|
+
if ((w.op === 'eq' || w.op === 'neq') && (w.value === undefined || Array.isArray(w.value))) ctx.addIssue({ code: 'invalid_type', path: ['where', i, 'value'], message: 'eq and neq take one value.', expected: 'string', input: w.value } as never)
|
|
68
|
+
})
|
|
69
|
+
if (q.sort && q.sort !== 'manual') {
|
|
70
|
+
const el = fields?.[q.sort.field]
|
|
71
|
+
if (fields && !el) ctx.addIssue({ code: 'custom', path: ['sort', 'field'], message: `The target has no field "${q.sort.field}".`, params: { code: 'unknown-field' } })
|
|
72
|
+
else if (el && !SORTABLE.includes(el)) ctx.addIssue({ code: 'custom', path: ['sort', 'field'], message: 'Only text, date and number fields can be sorted.', params: { code: 'not-sortable' } })
|
|
73
|
+
}
|
|
74
|
+
for (const k of ['exclude', 'pin', 'order'] as const) {
|
|
75
|
+
const ids = q[k]
|
|
76
|
+
if (ids && new Set(ids.map(String)).size !== ids.length)
|
|
77
|
+
ctx.addIssue({ code: 'custom', path: [k], message: 'Each entry may be listed once.', params: { code: 'duplicate' } })
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
const expand = (v: QueryStored, o: O, ctx: ExpandCtx): EntriesRead => ({ ...ctx.query(o.to, v), ...(ctx.draft ? { $path: ctx.at, $pins: (v.pin ?? []).map(String) } : {}) })
|
|
81
|
+
|
|
82
|
+
export const query = defineElement({
|
|
83
|
+
id: 'u90/query',
|
|
84
|
+
version: 1,
|
|
85
|
+
label: 'Query',
|
|
86
|
+
placement: 'content',
|
|
87
|
+
options: QueryOptions,
|
|
88
|
+
seed: stored,
|
|
89
|
+
stored,
|
|
90
|
+
read: () => EntriesReadSchema,
|
|
91
|
+
complete: () => [],
|
|
92
|
+
refs: (v, o): Ref[] => [{ kind: 'query', collection: o.to, query: v }],
|
|
93
|
+
toStored: async (s) => s,
|
|
94
|
+
expand,
|
|
95
|
+
expandSeed: expand,
|
|
96
|
+
toText: () => '',
|
|
97
|
+
translatable: () => [],
|
|
98
|
+
translation: null,
|
|
99
|
+
empty: () => ({}),
|
|
100
|
+
upgrade: {},
|
|
101
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z, type ZodType } from 'zod'
|
|
2
|
+
import { EntryReadSchema, Id, defineElement, requiredIssue, type EntryRead, type ExpandCtx, type Ref, type SeedCtx } from '../define'
|
|
3
|
+
import { path } from '../path'
|
|
4
|
+
|
|
5
|
+
/** Seeds name entries by key inside the target collection, or give an id. */
|
|
6
|
+
export const EntryRef = z.union([Id, z.strictObject({ $ref: z.strictObject({ collection: z.string().min(1).optional(), key: z.string().min(1) }) })])
|
|
7
|
+
export const resolveEntryRef = (ref: z.infer<typeof EntryRef>, collection: string, ctx: SeedCtx) =>
|
|
8
|
+
typeof ref === 'object' ? ctx.resolveEntry(ref.$ref.collection ?? collection, ref.$ref.key) : ref
|
|
9
|
+
export const uniqueIds = (ids: unknown[], ctx: z.RefinementCtx) => {
|
|
10
|
+
if (new Set(ids.map(String)).size !== ids.length)
|
|
11
|
+
ctx.addIssue({ code: 'custom', message: 'Each entry may be referenced once.', params: { code: 'duplicate' } })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const RelationOptions = z
|
|
15
|
+
.strictObject({
|
|
16
|
+
to: z.string().min(1),
|
|
17
|
+
cardinality: z.enum(['many-to-one', 'one-to-one', 'many-to-many', 'one-to-many']),
|
|
18
|
+
inverseOf: z.string().min(1).optional(),
|
|
19
|
+
depth: z.int().min(0).max(3).default(1),
|
|
20
|
+
})
|
|
21
|
+
.refine((o) => o.cardinality !== 'one-to-many' || o.inverseOf, { path: ['inverseOf'], message: 'one-to-many is always the derived side: name the many-to-one field on the target.' })
|
|
22
|
+
type O = z.infer<typeof RelationOptions>
|
|
23
|
+
/** The derived side stores nothing; its value is read from the target collection. */
|
|
24
|
+
export const isDerived = (o: O) => o.cardinality === 'one-to-many' || o.inverseOf !== undefined
|
|
25
|
+
const isMany = (o: O) => o.cardinality === 'many-to-many' || o.cardinality === 'one-to-many'
|
|
26
|
+
|
|
27
|
+
const stored = (o: O) => (isDerived(o) ? z.null() : isMany(o) ? z.array(Id).superRefine(uniqueIds) : Id.nullable())
|
|
28
|
+
type Stored = Id | Id[] | null
|
|
29
|
+
type SeedValue = z.infer<typeof EntryRef> | z.infer<typeof EntryRef>[] | null
|
|
30
|
+
const seed = (o: O): ZodType<SeedValue> => (isDerived(o) ? z.null() : isMany(o) ? z.array(EntryRef) : EntryRef.nullable())
|
|
31
|
+
const read = (o: O) => (isMany(o) ? z.array(EntryReadSchema) : EntryReadSchema.nullable())
|
|
32
|
+
const selfId = (at: string) => path.parse(at).id
|
|
33
|
+
|
|
34
|
+
const expand = (v: Stored, o: O, ctx: ExpandCtx): EntryRead | EntryRead[] | null => {
|
|
35
|
+
if (isDerived(o)) {
|
|
36
|
+
const inverse = ctx.inverse(o.to, o.inverseOf!, selfId(ctx.at))
|
|
37
|
+
return isMany(o) ? inverse : (inverse[0] ?? null)
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(v)) return v.map((id) => ctx.entry(o.to, id, o.depth))
|
|
40
|
+
return v === null ? null : ctx.entry(o.to, v, o.depth)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const relation = defineElement({
|
|
44
|
+
id: 'u90/relation',
|
|
45
|
+
version: 1,
|
|
46
|
+
label: 'Relation',
|
|
47
|
+
placement: 'collection',
|
|
48
|
+
options: RelationOptions,
|
|
49
|
+
seed,
|
|
50
|
+
stored,
|
|
51
|
+
read,
|
|
52
|
+
complete: (v, o, field) => (isDerived(o) ? [] : requiredIssue(Array.isArray(v) ? v.length > 0 : v != null, field)),
|
|
53
|
+
refs: (v, o, at): Ref[] => {
|
|
54
|
+
if (isDerived(o)) return [{ kind: 'inverse', collection: o.to, field: o.inverseOf!, id: selfId(at) }]
|
|
55
|
+
if (Array.isArray(v)) return v.map((id) => ({ kind: 'entry', collection: o.to, id, depth: o.depth }))
|
|
56
|
+
return v === null ? [] : [{ kind: 'entry', collection: o.to, id: v, depth: o.depth }]
|
|
57
|
+
},
|
|
58
|
+
toStored: async (s, o, ctx) => {
|
|
59
|
+
if (isDerived(o) || s === null) return null
|
|
60
|
+
return Array.isArray(s) ? s.map((r) => resolveEntryRef(r, o.to, ctx)) : resolveEntryRef(s, o.to, ctx)
|
|
61
|
+
},
|
|
62
|
+
expand,
|
|
63
|
+
expandSeed: (s, o, ctx) => expand(isDerived(o) || s === null ? null : Array.isArray(s) ? s.filter((r) => typeof r !== 'object') as Id[] : typeof s === 'object' ? null : s, o, ctx),
|
|
64
|
+
toText: () => '',
|
|
65
|
+
translatable: () => [],
|
|
66
|
+
translation: null,
|
|
67
|
+
empty: (o) => (isDerived(o) ? null : isMany(o) ? [] : null),
|
|
68
|
+
upgrade: {},
|
|
69
|
+
})
|