@unitninety/site-kit 0.0.0-stage → 1.6.1
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 +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -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 +1010 -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 +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -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 +12 -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,103 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { TEL_RE, URL_RE, defineElement, requiredIssue } from '../define'
|
|
3
|
+
import { TOKEN, resolveTokens, textOfRead, tokenKeys } from '../tokens'
|
|
4
|
+
|
|
5
|
+
export const TextOptions = z.strictObject({
|
|
6
|
+
multiline: z.boolean().default(false),
|
|
7
|
+
format: z.enum(['plain', 'email', 'tel', 'url', 'code']).default('plain'),
|
|
8
|
+
maxLength: z.int().positive().optional(),
|
|
9
|
+
/** Checked here so a bad pattern is a declaration issue, never a throw from the stored schema's check. */
|
|
10
|
+
pattern: z
|
|
11
|
+
.string()
|
|
12
|
+
.refine((p) => { try { new RegExp(p); return true } catch { return false } }, 'pattern must be a valid regular expression')
|
|
13
|
+
.optional(),
|
|
14
|
+
})
|
|
15
|
+
type O = z.infer<typeof TextOptions>
|
|
16
|
+
|
|
17
|
+
const FORMAT_MESSAGE: Record<O['format'], string> = {
|
|
18
|
+
plain: '',
|
|
19
|
+
code: '',
|
|
20
|
+
email: 'Expected an email address.',
|
|
21
|
+
tel: 'Expected a phone number.',
|
|
22
|
+
url: 'Expected an absolute URL or a root-relative path.',
|
|
23
|
+
}
|
|
24
|
+
const formatOk = (v: string, format: O['format']) =>
|
|
25
|
+
v === '' ||
|
|
26
|
+
format === 'plain' ||
|
|
27
|
+
format === 'code' ||
|
|
28
|
+
(format === 'email' && z.email().safeParse(v).success) ||
|
|
29
|
+
(format === 'tel' && TEL_RE.test(v)) ||
|
|
30
|
+
(format === 'url' && URL_RE.test(v))
|
|
31
|
+
|
|
32
|
+
const stored = (o: O) => {
|
|
33
|
+
let s = z.string()
|
|
34
|
+
if (o.maxLength) s = s.max(o.maxLength)
|
|
35
|
+
return s.superRefine((v, ctx) => {
|
|
36
|
+
if (!o.multiline && /[\r\n]/.test(v))
|
|
37
|
+
ctx.addIssue({ code: 'custom', message: 'Line breaks are not allowed here.', params: { code: 'multiline' } })
|
|
38
|
+
if (!formatOk(v, o.format))
|
|
39
|
+
ctx.addIssue({ code: 'custom', message: FORMAT_MESSAGE[o.format], params: { code: 'format' } })
|
|
40
|
+
if (o.pattern && v !== '' && !new RegExp(o.pattern).test(v))
|
|
41
|
+
ctx.addIssue({ code: 'custom', message: 'Does not match the required pattern.', params: { code: 'pattern' } })
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
/** A draft read's pieces: plain text, and each resolved `{key}` token as its Single's text, so a draft render can mark it and an edit can save the token back (R38). */
|
|
45
|
+
export type TextSegment = string | { key: string; text: string }
|
|
46
|
+
export type TextRead = { text: string; segments?: TextSegment[]; $path?: string }
|
|
47
|
+
|
|
48
|
+
const segmentsOf = (v: string, single: (key: string) => unknown): TextSegment[] | undefined => {
|
|
49
|
+
const out: TextSegment[] = []
|
|
50
|
+
let last = 0
|
|
51
|
+
let resolved = false
|
|
52
|
+
for (const m of v.matchAll(TOKEN)) {
|
|
53
|
+
const t = textOfRead(single(m[1]))
|
|
54
|
+
if (t === undefined) continue // an unknown key stays literal, inside the surrounding text
|
|
55
|
+
if (m.index! > last) out.push(v.slice(last, m.index))
|
|
56
|
+
out.push({ key: m[1], text: t })
|
|
57
|
+
resolved = true
|
|
58
|
+
last = m.index! + m[0].length
|
|
59
|
+
}
|
|
60
|
+
if (!resolved) return undefined
|
|
61
|
+
if (last < v.length) out.push(v.slice(last))
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
const read = z.object({
|
|
65
|
+
text: z.string(),
|
|
66
|
+
segments: z.array(z.union([z.string(), z.object({ key: z.string(), text: z.string() })])).optional(),
|
|
67
|
+
$path: z.string().optional(),
|
|
68
|
+
})
|
|
69
|
+
/** A validated write can never store non-string text (the stored schema is `z.string()`); this guards a render against bad data. */
|
|
70
|
+
const expand = (v: string, o: O, ctx: { at: string; draft: boolean; single(key: string): unknown }): TextRead => {
|
|
71
|
+
const marker = ctx.draft ? { $path: ctx.at } : {}
|
|
72
|
+
if (typeof v !== 'string') return { text: '', ...marker }
|
|
73
|
+
if (o.format === 'code') return { text: v, ...marker }
|
|
74
|
+
const segments = ctx.draft ? segmentsOf(v, ctx.single) : undefined
|
|
75
|
+
return { text: resolveTokens(v, ctx.single), ...(segments ? { segments } : {}), ...marker }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const text = defineElement({
|
|
79
|
+
id: 'u90/text',
|
|
80
|
+
version: 1,
|
|
81
|
+
label: 'Text',
|
|
82
|
+
placement: 'any',
|
|
83
|
+
options: TextOptions,
|
|
84
|
+
seed: stored,
|
|
85
|
+
stored,
|
|
86
|
+
read: () => read,
|
|
87
|
+
complete: (v, o, field) => requiredIssue(typeof v === 'string' && v.trim() !== '', field),
|
|
88
|
+
/** Guards a non-string (bad data the stored schema rejects) so collecting refs never throws. */
|
|
89
|
+
refs: (v, o) => (o.format === 'code' || typeof v !== 'string' ? [] : tokenKeys(v).map((key) => ({ kind: 'single' as const, key }))),
|
|
90
|
+
toStored: async (seed) => seed,
|
|
91
|
+
expand,
|
|
92
|
+
expandSeed: expand,
|
|
93
|
+
toText: (v) => v,
|
|
94
|
+
translatable: (v, o) => (o.format === 'plain' && v !== '' ? [{ path: [], value: v }] : []),
|
|
95
|
+
translation: {
|
|
96
|
+
applies: (o) => o.format === 'plain',
|
|
97
|
+
value: (o) => stored(o),
|
|
98
|
+
pick: (v) => (v === '' ? null : v),
|
|
99
|
+
apply: (_base, t) => t,
|
|
100
|
+
},
|
|
101
|
+
empty: () => '',
|
|
102
|
+
upgrade: {},
|
|
103
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Id, MediaAssetSchema, defineElement, requiredIssue, type ExpandCtx, type MediaAsset, type MediaRecord, type Ref, type SeedCtx } from '../define'
|
|
3
|
+
import { ImageOptions, expandImage, image, imageSeed, imageStored, imageToStored, type ImageRead } from './image'
|
|
4
|
+
|
|
5
|
+
export const VideoOptions = z.strictObject({
|
|
6
|
+
poster: z.strictObject({ label: z.string().optional(), ...ImageOptions.shape }).default({ variants: {} }),
|
|
7
|
+
})
|
|
8
|
+
type O = z.infer<typeof VideoOptions>
|
|
9
|
+
const posterOptions = (o: O) => ImageOptions.parse({ constraints: o.poster.constraints, variants: o.poster.variants })
|
|
10
|
+
|
|
11
|
+
const seconds = z.number().nonnegative()
|
|
12
|
+
const settings = {
|
|
13
|
+
posterTime: seconds,
|
|
14
|
+
startTime: seconds,
|
|
15
|
+
endTime: seconds.nullable(),
|
|
16
|
+
autoplay: z.boolean(),
|
|
17
|
+
muted: z.boolean(),
|
|
18
|
+
loop: z.boolean(),
|
|
19
|
+
controls: z.boolean(),
|
|
20
|
+
playsInline: z.boolean(),
|
|
21
|
+
preload: z.enum(['none', 'metadata', 'auto']),
|
|
22
|
+
}
|
|
23
|
+
const rules = (v: { startTime: number; endTime: number | null; autoplay: boolean; muted: boolean }, ctx: z.RefinementCtx) => {
|
|
24
|
+
if (v.endTime !== null && v.endTime <= v.startTime)
|
|
25
|
+
ctx.addIssue({ code: 'custom', path: ['endTime'], message: 'End time must be after start time.', params: { code: 'end-before-start' } })
|
|
26
|
+
if (v.autoplay && !v.muted)
|
|
27
|
+
ctx.addIssue({ code: 'custom', path: ['muted'], message: 'Autoplay requires muted playback.', params: { code: 'autoplay-muted' } })
|
|
28
|
+
}
|
|
29
|
+
const stored = (o: O) => z.strictObject({ asset: Id, poster: imageStored(posterOptions(o)), ...settings }).superRefine(rules).nullable()
|
|
30
|
+
type Stored = z.infer<ReturnType<typeof stored>>
|
|
31
|
+
const DEFAULTS = { posterTime: 0, startTime: 0, endTime: null, autoplay: false, muted: true, loop: false, controls: true, playsInline: true, preload: 'metadata' as const }
|
|
32
|
+
const seed = (o: O) =>
|
|
33
|
+
z
|
|
34
|
+
.strictObject({
|
|
35
|
+
asset: z.union([Id, z.strictObject({ source: z.string().trim().min(1) })]),
|
|
36
|
+
poster: imageSeed(posterOptions(o)).default(null),
|
|
37
|
+
posterTime: seconds.default(0),
|
|
38
|
+
startTime: seconds.default(0),
|
|
39
|
+
endTime: seconds.nullable().default(null),
|
|
40
|
+
autoplay: z.boolean().default(false),
|
|
41
|
+
muted: z.boolean().default(true),
|
|
42
|
+
loop: z.boolean().default(false),
|
|
43
|
+
controls: z.boolean().default(true),
|
|
44
|
+
playsInline: z.boolean().default(true),
|
|
45
|
+
preload: z.enum(['none', 'metadata', 'auto']).default('metadata'),
|
|
46
|
+
})
|
|
47
|
+
.superRefine(rules)
|
|
48
|
+
.nullable()
|
|
49
|
+
|
|
50
|
+
export type MediaAssetRead = MediaAsset
|
|
51
|
+
export const mediaAssetRead = (m: MediaRecord | undefined): { asset: MediaAssetRead | null; status: 'ready' | 'missing' } => {
|
|
52
|
+
if (!m) return { asset: null, status: 'missing' }
|
|
53
|
+
const { renditions: _r, ...asset } = m
|
|
54
|
+
return { asset, status: 'ready' }
|
|
55
|
+
}
|
|
56
|
+
export type VideoRead = ({ asset: MediaAssetRead | null; status: 'ready' | 'missing'; poster: ImageRead } & z.infer<z.ZodObject<typeof settings>> & { $path?: string }) | null
|
|
57
|
+
const read = (o: O) => z.object({ asset: MediaAssetSchema.nullable(), status: z.enum(['ready', 'missing']), poster: image.read(posterOptions(o)), ...settings, $path: z.string().optional() }).nullable()
|
|
58
|
+
const expand = (v: Stored, o: O, ctx: ExpandCtx): VideoRead => {
|
|
59
|
+
if (v === null) return null
|
|
60
|
+
const { asset, poster, ...rest } = v
|
|
61
|
+
return { ...mediaAssetRead(ctx.media(asset)), poster: expandImage(poster, posterOptions(o), { ...ctx, at: `${ctx.at}/poster` }), ...rest, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const video = defineElement({
|
|
65
|
+
id: 'u90/video',
|
|
66
|
+
version: 1,
|
|
67
|
+
label: 'Video',
|
|
68
|
+
placement: 'any',
|
|
69
|
+
options: VideoOptions,
|
|
70
|
+
seed,
|
|
71
|
+
stored,
|
|
72
|
+
read,
|
|
73
|
+
complete: (v, o, field) => {
|
|
74
|
+
if (v == null) return requiredIssue(false, field)
|
|
75
|
+
return image.complete(v.poster, posterOptions(o), { required: false, hasDefault: false }).map((i) => ({ ...i, path: ['poster', ...i.path] }))
|
|
76
|
+
},
|
|
77
|
+
refs: (v, o) => (v ? [{ kind: 'media', id: v.asset } as Ref, ...image.refs(v.poster, posterOptions(o), '')] : []),
|
|
78
|
+
toStored: async (s, o, ctx: SeedCtx) => {
|
|
79
|
+
if (s === null) return null
|
|
80
|
+
const { asset, poster, ...rest } = s
|
|
81
|
+
return { asset: typeof asset === 'object' ? await ctx.importAsset(asset.source) : asset, poster: await imageToStored(poster, posterOptions(o), ctx), ...rest }
|
|
82
|
+
},
|
|
83
|
+
expand,
|
|
84
|
+
expandSeed: (s, o, ctx) => {
|
|
85
|
+
if (s === null) return null
|
|
86
|
+
const { asset, poster, ...rest } = s
|
|
87
|
+
const src = typeof asset === 'object' ? asset.source : null
|
|
88
|
+
const a = src ? { asset: { id: src, url: src, mimeType: '', width: null, height: null, filename: src.split('/').pop() ?? src }, status: 'ready' as const } : mediaAssetRead(ctx.media(asset as Id))
|
|
89
|
+
return { ...a, poster: image.expandSeed(poster, posterOptions(o), { ...ctx, at: `${ctx.at}/poster` }), ...DEFAULTS, ...rest, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
90
|
+
},
|
|
91
|
+
toText: (v, o) => (v ? image.toText(v.poster, posterOptions(o)) : ''),
|
|
92
|
+
translatable: (v, o) => (v ? image.translatable(v.poster, posterOptions(o)).map((t) => ({ ...t, path: ['poster', ...t.path] })) : []),
|
|
93
|
+
// A video translates its poster's alt text and caption, through the image codec (ruling R13).
|
|
94
|
+
translation: {
|
|
95
|
+
applies: () => true,
|
|
96
|
+
// A video with no poster has nothing to translate, as an image with no value has not.
|
|
97
|
+
present: (v) => v !== null && v.poster !== null,
|
|
98
|
+
value: (o) => image.translation!.value(posterOptions(o)),
|
|
99
|
+
pick: (v, o) => (v ? image.translation!.pick(v.poster, posterOptions(o)) : null),
|
|
100
|
+
apply: (base, t, o) => {
|
|
101
|
+
if (!base) return undefined
|
|
102
|
+
const poster = image.translation!.apply(base.poster ?? undefined, t, posterOptions(o))
|
|
103
|
+
return poster === undefined ? undefined : { ...base, poster }
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
empty: () => null,
|
|
107
|
+
upgrade: {},
|
|
108
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SLOT, isLeaf, type DocumentDecl, type EntryRead, type EntryRecord, type ExpandCtx, type FieldDecl, type Id, type MediaRecord, type PageRecord, type Schema } from '../define'
|
|
2
|
+
import { expandLeaf } from '../leaf'
|
|
3
|
+
import { path } from '../path'
|
|
4
|
+
import { getContainer, getElement } from '../registry'
|
|
5
|
+
import { record } from '../containers/group'
|
|
6
|
+
import { prefixedRoute } from '../prefix'
|
|
7
|
+
|
|
8
|
+
export type Resolved = {
|
|
9
|
+
media?: Map<string, MediaRecord>
|
|
10
|
+
pages?: Map<string, PageRecord>
|
|
11
|
+
/** Singles already expanded to their read values by the caller. */
|
|
12
|
+
singles?: Map<string, unknown>
|
|
13
|
+
entries?: Map<string, EntryRecord>
|
|
14
|
+
queries?: Map<string, { ids: Id[]; total: number }>
|
|
15
|
+
inverses?: Map<string, Id[]>
|
|
16
|
+
}
|
|
17
|
+
export const entryKey = (collection: string, id: Id) => `${collection}:${id}`
|
|
18
|
+
export const queryKey = (collection: string, query: unknown) => `${collection}:${JSON.stringify(query ?? {})}`
|
|
19
|
+
export const inverseKey = (collection: string, field: string, id: Id) => `${collection}.${field}=${id}`
|
|
20
|
+
|
|
21
|
+
const MAX_DEPTH = 3
|
|
22
|
+
export function makeCtx(schema: Schema, r: Resolved, at: string, draft: boolean, budget = MAX_DEPTH, fallbacks?: ReadonlySet<string>, draftLocale?: string, localePrefix?: string): ExpandCtx {
|
|
23
|
+
const under = (route: string) => (localePrefix ? prefixedRoute(localePrefix, route) : route)
|
|
24
|
+
const ctx: ExpandCtx = {
|
|
25
|
+
at,
|
|
26
|
+
draft,
|
|
27
|
+
...(draft && fallbacks?.size ? { fallbacks } : {}),
|
|
28
|
+
...(draftLocale ? { draftLocale } : {}),
|
|
29
|
+
media: (id) => r.media?.get(String(id)),
|
|
30
|
+
page: (id) => { const p = r.pages?.get(String(id)); return p && localePrefix ? { ...p, route: under(p.route) } : p },
|
|
31
|
+
single: (key) => r.singles?.get(key),
|
|
32
|
+
entry: (collection, id, depth): EntryRead => {
|
|
33
|
+
const rec = r.entries?.get(entryKey(collection, id))
|
|
34
|
+
if (!rec) return { id, collection, status: 'missing' }
|
|
35
|
+
const base: EntryRead = { id, collection, status: 'ready', slug: rec.slug, ...(rec.route ? { route: under(rec.route) } : {}) }
|
|
36
|
+
const decl = Object.hasOwn(schema.collections, collection) ? schema.collections[collection] : undefined
|
|
37
|
+
if (Math.min(depth, budget) <= 0 || !decl) return base
|
|
38
|
+
const inner = makeCtx(schema, r, path.child(path.doc('entry', id), 'fields'), draft, Math.min(depth, budget) - 1, undefined, draftLocale, localePrefix)
|
|
39
|
+
return { ...base, fields: expandField(schema, { container: 'u90/group', fields: decl.fields, order: decl.order }, rec.fields, inner) as Record<string, unknown> }
|
|
40
|
+
},
|
|
41
|
+
query: (collection, q) => {
|
|
42
|
+
const hit = r.queries?.get(queryKey(collection, q))
|
|
43
|
+
return hit ? { entries: hit.ids.map((id) => ctx.entry(collection, id, 1)), total: hit.total } : { entries: [], total: 0 }
|
|
44
|
+
},
|
|
45
|
+
inverse: (collection, field, id) => (r.inverses?.get(inverseKey(collection, field, id)) ?? []).map((x) => ctx.entry(collection, x, 1)),
|
|
46
|
+
}
|
|
47
|
+
return ctx
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const withAt = (ctx: ExpandCtx, at: string): ExpandCtx => ({ ...ctx, at })
|
|
51
|
+
|
|
52
|
+
export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx: ExpandCtx): unknown {
|
|
53
|
+
if (isLeaf(decl)) return expandLeaf(getElement(decl.element), decl, value, ctx)
|
|
54
|
+
const container = getContainer(decl.container)
|
|
55
|
+
const children = container.children(decl, decl.container === 'u90/group' ? (record(value) ? value : {}) : value, schema)
|
|
56
|
+
if (decl.container === 'u90/group') return Object.fromEntries(children.map((c) => [c.key, expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))]))
|
|
57
|
+
const items = Array.isArray(value) ? value : []
|
|
58
|
+
return children.map((c, i) => {
|
|
59
|
+
const inner = expandField(schema, c.decl, c.value, withAt(ctx, path.child(ctx.at, c.key)))
|
|
60
|
+
// A leaf item reads as its element's value (which carries its own `$path`); a group item, like a zone item, carries the item's path in draft.
|
|
61
|
+
if (decl.container === 'u90/list') return isLeaf(decl.item) ? inner : { _id: c.key, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
62
|
+
const item = items[i]
|
|
63
|
+
const block = record(item) ? String(item._block) : ''
|
|
64
|
+
if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block }
|
|
65
|
+
return { _id: c.key, _block: block, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `fallbacks` are the overlay keys the platform listed for this document (draft only): a leaf at one reads with `$fallback`. An entry expanded inside this document is another document and is not marked.
|
|
71
|
+
* `draftLocale` is the language a translated frame reads in, read from a draft request's `DRAFT_LOCALE_PARAM`: internal link hrefs, here and in entries expanded inside this document, keep it. Unlike `fallbacks`, it is not gated on `draft` here.
|
|
72
|
+
* `localePrefix` is the address prefix of the language the reply is read in (`/fr`): page and entry routes carry it.
|
|
73
|
+
*/
|
|
74
|
+
export function expand(schema: Schema, docDecl: DocumentDecl, doc: any, resolved: Resolved, opts: { at: string; draft: boolean; fallbacks?: readonly string[]; draftLocale?: string; localePrefix?: string }): unknown {
|
|
75
|
+
const ctx = makeCtx(schema, resolved, opts.at, opts.draft, MAX_DEPTH, opts.fallbacks ? new Set(opts.fallbacks) : undefined, opts.draftLocale, opts.localePrefix)
|
|
76
|
+
switch (docDecl.kind) {
|
|
77
|
+
case 'page':
|
|
78
|
+
case 'layout':
|
|
79
|
+
return { blocks: expandField(schema, docDecl.blocks, doc?.blocks, withAt(ctx, path.child(opts.at, 'blocks'))) }
|
|
80
|
+
case 'entry': {
|
|
81
|
+
const c = Object.hasOwn(schema.collections, docDecl.collection) ? schema.collections[docDecl.collection] : undefined
|
|
82
|
+
if (!c) return null
|
|
83
|
+
return {
|
|
84
|
+
fields: expandField(schema, { container: 'u90/group', fields: c.fields, order: c.order }, doc?.fields, withAt(ctx, path.child(opts.at, 'fields'))),
|
|
85
|
+
...(c.template ? { blocks: expandField(schema, c.template, doc?.blocks, withAt(ctx, path.child(opts.at, 'blocks'))) } : {}),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
case 'single': {
|
|
89
|
+
const s = Object.hasOwn(schema.singles, docDecl.key) ? schema.singles[docDecl.key] : undefined
|
|
90
|
+
if (!s) return null
|
|
91
|
+
return expandField(schema, s.field, doc?.value, ctx)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { DocumentDecl, Schema } from '../define'
|
|
2
|
+
import { hasStoredShape } from '../leaf'
|
|
3
|
+
import { overlayKey } from '../path'
|
|
4
|
+
import { walkDocument, type LeafNode } from '../walk'
|
|
5
|
+
|
|
6
|
+
export { overlayKey }
|
|
7
|
+
|
|
8
|
+
export type OverlayValues = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
type Found = { node: LeafNode; options: unknown; key: string }
|
|
11
|
+
|
|
12
|
+
/** Every leaf whose element has a codec that applies to its options, that is not declared `translatable: false`, and whose stored value has its element's shape and a part the codec translates. */
|
|
13
|
+
function collect(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string): Found[] {
|
|
14
|
+
if ((docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) || (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key))) return []
|
|
15
|
+
const out: Found[] = []
|
|
16
|
+
walkDocument(schema, docDecl, doc, {
|
|
17
|
+
leaf: (node) => {
|
|
18
|
+
const codec = node.entry.translation
|
|
19
|
+
if (!codec || node.decl.translatable === false) return
|
|
20
|
+
const o = node.entry.options.safeParse(node.decl.options ?? {})
|
|
21
|
+
if (!o.success || !codec.applies(o.data)) return
|
|
22
|
+
if (node.value === undefined || node.value === null || !hasStoredShape(node.entry, o.data, node.value)) return
|
|
23
|
+
if (codec.present && !codec.present(node.value, o.data)) return
|
|
24
|
+
out.push({ node, options: o.data, key: overlayKey(node.at) })
|
|
25
|
+
},
|
|
26
|
+
}, at)
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function translatableLeaves(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string): { key: string; at: string; value: unknown }[] {
|
|
31
|
+
return collect(schema, docDecl, doc, at).map(({ node, options, key }) => ({ key, at: node.at, value: node.entry.translation!.pick(node.value, options) }))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lays language overlays onto a deep copy of `doc`. For each translatable leaf the first layer holding a
|
|
36
|
+
* value that passes the codec's schema wins. A leaf no layer translates keeps its base value and its key
|
|
37
|
+
* goes in `fellBack`, unless there is nothing to translate in it. A leaf won by a later layer (the first layer is the requested language, the rest are fallbacks) is in `fellBack` too. A layer whose value the codec cannot land (`apply` answers undefined) is skipped.
|
|
38
|
+
*/
|
|
39
|
+
export function applyOverlay(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string, layers: OverlayValues[]): { doc: unknown; fellBack: string[] } {
|
|
40
|
+
const copy = structuredClone(doc)
|
|
41
|
+
const fellBack: string[] = []
|
|
42
|
+
for (const { node, options, key } of collect(schema, docDecl, copy, at)) {
|
|
43
|
+
const codec = node.entry.translation!
|
|
44
|
+
const schemaOf = codec.value(options)
|
|
45
|
+
let won = -1
|
|
46
|
+
for (const [i, layer] of layers.entries()) {
|
|
47
|
+
if (!Object.hasOwn(layer, key)) continue
|
|
48
|
+
const parsed = schemaOf.safeParse(layer[key])
|
|
49
|
+
if (!parsed.success) continue
|
|
50
|
+
const landed = codec.apply(node.value, parsed.data, options)
|
|
51
|
+
if (landed === undefined) continue
|
|
52
|
+
node.set(landed)
|
|
53
|
+
won = i
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
if (won > 0 || (won < 0 && codec.pick(node.value, options) !== null)) fellBack.push(key)
|
|
57
|
+
}
|
|
58
|
+
return { doc: copy, fellBack }
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DocumentDecl, ExpandCtx, Ref, Schema } from '../define'
|
|
2
|
+
import { hasStoredShape } from '../leaf'
|
|
3
|
+
import { walkDocument } from '../walk'
|
|
4
|
+
|
|
5
|
+
/** An ExpandCtx that resolves nothing and records each lookup as the ref that would answer it. */
|
|
6
|
+
const recorder = (at: string, add: (ref: Ref) => void): ExpandCtx => ({
|
|
7
|
+
at,
|
|
8
|
+
draft: false,
|
|
9
|
+
media: (id) => { add({ kind: 'media', id }); return undefined },
|
|
10
|
+
page: (id) => { add({ kind: 'page', id }); return undefined },
|
|
11
|
+
single: (key) => { add({ kind: 'single', key }); return undefined },
|
|
12
|
+
entry: (collection, id, depth) => { add({ kind: 'entry', collection, id, depth }); return { id, collection, status: 'missing' } },
|
|
13
|
+
query: (collection, query) => { add({ kind: 'query', collection, query }); return { entries: [], total: 0 } },
|
|
14
|
+
inverse: (collection, field, id) => { add({ kind: 'inverse', collection, field, id }); return [] },
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everything the document's read needs fetched, each once. A stored value (a `null` counts as the element's
|
|
19
|
+
* empty value) asks for its element's `refs`. An absent field reads as its declared default, so it asks for
|
|
20
|
+
* exactly the lookups reading that default makes: ids, tokens and queries do; a `{ source }` asset, a `$ref`
|
|
21
|
+
* or a page or entry named by key reads as missing until seeded and asks for nothing.
|
|
22
|
+
*/
|
|
23
|
+
export function collectRefs(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string): Ref[] {
|
|
24
|
+
if ((docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) || (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key))) return []
|
|
25
|
+
const seen = new Set<string>()
|
|
26
|
+
const refs: Ref[] = []
|
|
27
|
+
const add = (ref: Ref) => {
|
|
28
|
+
const key = JSON.stringify(ref)
|
|
29
|
+
if (!seen.has(key)) { seen.add(key); refs.push(ref) }
|
|
30
|
+
}
|
|
31
|
+
walkDocument(schema, docDecl, doc, {
|
|
32
|
+
leaf: ({ entry, decl, value, at: here }) => {
|
|
33
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
34
|
+
if (!o.success) return
|
|
35
|
+
if (value === undefined && decl.default !== undefined) {
|
|
36
|
+
const seed = entry.seed(o.data).safeParse(decl.default)
|
|
37
|
+
if (seed.success) entry.expandSeed(seed.data, o.data, recorder(here, add))
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if (value !== undefined && value !== null && !hasStoredShape(entry, o.data, value)) return
|
|
41
|
+
entry.refs(value ?? entry.empty(o.data), o.data, here).forEach(add)
|
|
42
|
+
},
|
|
43
|
+
}, at)
|
|
44
|
+
return refs
|
|
45
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { SLOT, isLeaf, type DocumentDecl, type FieldDecl, type Issue, type ListDecl, type Schema, type SeedCtx, type ZoneDecl } from '../define'
|
|
2
|
+
import { isDocumentId } from '../ids'
|
|
3
|
+
import { zodIssues } from '../leaf'
|
|
4
|
+
import { path } from '../path'
|
|
5
|
+
import { CURRENT_CONTRACT, getElement } from '../registry'
|
|
6
|
+
import { record } from '../containers/group'
|
|
7
|
+
import { orderedEntries } from '../order'
|
|
8
|
+
|
|
9
|
+
export class SeedError extends Error {
|
|
10
|
+
constructor(public failures: { path: string; message: string }[]) {
|
|
11
|
+
super(failures.map((f) => `${f.path}: ${f.message}`).join('\n'))
|
|
12
|
+
this.name = 'SeedError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
type Fail = (p: string, e: unknown) => void
|
|
16
|
+
const collector = () => {
|
|
17
|
+
const failures: { path: string; message: string }[] = []
|
|
18
|
+
const fail: Fail = (p, e) => failures.push({ path: p, message: e instanceof Error ? e.message : String(e) })
|
|
19
|
+
return { failures, fail }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One declared field's stored value, from an explicit seed value or its declared default. */
|
|
23
|
+
async function seedField(schema: Schema, decl: FieldDecl, v: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<unknown> {
|
|
24
|
+
if (isLeaf(decl)) {
|
|
25
|
+
const entry = getElement(decl.element)
|
|
26
|
+
const parsed = entry.options.safeParse(decl.options ?? {})
|
|
27
|
+
if (!parsed.success) { fail(here, 'The field’s declared options are invalid.'); return null }
|
|
28
|
+
const o = parsed.data
|
|
29
|
+
const input = v !== undefined ? v : decl.default
|
|
30
|
+
if (input === undefined) return entry.empty(o)
|
|
31
|
+
try { return await entry.toStored(entry.seed(o).parse(input), o, ctx) } catch (e) { fail(here, e); return entry.empty(o) }
|
|
32
|
+
}
|
|
33
|
+
if (decl.container === 'u90/group') {
|
|
34
|
+
const src = record(v) ? v : {}
|
|
35
|
+
const out: Record<string, unknown> = {}
|
|
36
|
+
for (const [name, f] of orderedEntries(decl.fields, decl.order)) out[name] = await seedField(schema, f, src[name], ctx, path.child(here, name), fail)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
if (decl.container === 'u90/list') {
|
|
40
|
+
const out: unknown[] = []
|
|
41
|
+
for (const item of Array.isArray(v) ? v : (decl.default ?? [])) out.push(await seedListItem(schema, decl, item, ctx, here, fail))
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
const out: unknown[] = []
|
|
45
|
+
for (const item of Array.isArray(v) ? v : (decl.default ?? []).map((name) => ({ _block: name }))) {
|
|
46
|
+
const block = await seedZoneItem(schema, decl, item, ctx, here, fail)
|
|
47
|
+
if (block) out.push(block)
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
async function seedListItem(schema: Schema, decl: ListDecl, item: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<Record<string, unknown>> {
|
|
52
|
+
const _id = ctx.newId()
|
|
53
|
+
const inner = await seedField(schema, decl.item, item, ctx, path.child(here, _id), fail)
|
|
54
|
+
return isLeaf(decl.item) ? { _id, _value: inner } : { _id, ...(inner as object) }
|
|
55
|
+
}
|
|
56
|
+
async function seedZoneItem(schema: Schema, decl: ZoneDecl, item: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<Record<string, unknown> | null> {
|
|
57
|
+
const _id = ctx.newId()
|
|
58
|
+
const block = record(item) && typeof item._block === 'string' ? item._block : ''
|
|
59
|
+
if (block === SLOT) return { _id, _block: SLOT }
|
|
60
|
+
const declared = Object.hasOwn(schema.blocks, block) ? schema.blocks[block] : undefined
|
|
61
|
+
if (!declared) { fail(path.child(here, _id), `No block named "${block}".`); return null }
|
|
62
|
+
if (!decl.allow.includes(block)) fail(path.child(here, _id), `"${block}" is not allowed here.`)
|
|
63
|
+
const { _block: _b, ...rest } = item as Record<string, unknown>
|
|
64
|
+
return { _id, _block: block, ...((await seedField(schema, { container: 'u90/group', fields: declared.fields, order: declared.order }, rest, ctx, path.child(here, _id), fail)) as object) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Declared defaults, or an explicit seed value, to stored JSON. Every failing path is collected before throwing. */
|
|
68
|
+
export async function seed(schema: Schema, docDecl: DocumentDecl, value: unknown, ctx: SeedCtx, at: string): Promise<unknown> {
|
|
69
|
+
const { failures, fail } = collector()
|
|
70
|
+
let doc: unknown
|
|
71
|
+
switch (docDecl.kind) {
|
|
72
|
+
case 'page':
|
|
73
|
+
case 'layout': {
|
|
74
|
+
// A blocks array, or `{ blocks }` (the stored document's own shape); absent means the zone's default blocks.
|
|
75
|
+
const blocks = Array.isArray(value) ? value : record(value) && Array.isArray(value.blocks) ? value.blocks : undefined
|
|
76
|
+
if (value !== undefined && blocks === undefined)
|
|
77
|
+
throw new SeedError([{ path: path.child(at, 'blocks'), message: 'Seed a page or layout with a blocks array, or { blocks }.' }])
|
|
78
|
+
doc = { _contract: CURRENT_CONTRACT, blocks: await seedField(schema, docDecl.blocks, blocks, ctx, path.child(at, 'blocks'), fail) }
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
case 'entry': {
|
|
82
|
+
const c = Object.hasOwn(schema.collections, docDecl.collection) ? schema.collections[docDecl.collection] : undefined
|
|
83
|
+
if (!c) throw new SeedError([{ path: at, message: `No collection named "${docDecl.collection}".` }])
|
|
84
|
+
const v = record(value) ? value : {}
|
|
85
|
+
doc = {
|
|
86
|
+
_contract: CURRENT_CONTRACT,
|
|
87
|
+
fields: await seedField(schema, { container: 'u90/group', fields: c.fields, order: c.order }, v.fields, ctx, path.child(at, 'fields'), fail),
|
|
88
|
+
...(c.template ? { blocks: await seedField(schema, c.template, v.blocks, ctx, path.child(at, 'blocks'), fail) } : {}),
|
|
89
|
+
}
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
case 'single': {
|
|
93
|
+
const s = Object.hasOwn(schema.singles, docDecl.key) ? schema.singles[docDecl.key] : undefined
|
|
94
|
+
if (!s) throw new SeedError([{ path: at, message: `No Single named "${docDecl.key}".` }])
|
|
95
|
+
doc = { _contract: CURRENT_CONTRACT, value: await seedField(schema, s.field, value, ctx, at, fail) }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (failures.length) throw new SeedError(failures)
|
|
99
|
+
return doc
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One new item for a list or a zone, as the write path's `insert` stores it: a fresh `_id`, and every
|
|
104
|
+
* field filled from the declared defaults unless `value` gives it. A zone item names its block with
|
|
105
|
+
* `_block`; a SLOT cannot be inserted. Every failing path is collected and thrown as one SeedError.
|
|
106
|
+
*/
|
|
107
|
+
export async function seedItem(schema: Schema, decl: ListDecl | ZoneDecl, value: unknown, ctx: SeedCtx, at: string): Promise<Record<string, unknown>> {
|
|
108
|
+
if (decl.container === 'u90/zone' && record(value) && value._block === SLOT) throw new SeedError([{ path: at, message: 'A SLOT cannot be inserted.' }])
|
|
109
|
+
const { failures, fail } = collector()
|
|
110
|
+
const item = decl.container === 'u90/list' ? await seedListItem(schema, decl, value, ctx, at, fail) : await seedZoneItem(schema, decl, value, ctx, at, fail)
|
|
111
|
+
if (failures.length || !item) throw new SeedError(failures.length ? failures : [{ path: at, message: 'Nothing to insert.' }])
|
|
112
|
+
return item
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type SeedKnown = { page(key: string): boolean; entry(collection: string, key: string): boolean }
|
|
116
|
+
/** The page and entry keys a leaf's parsed seed value names: a link's `to.page` or `to.entry`, a relation's or selection's `$ref`. */
|
|
117
|
+
const namedKeys = (element: string, options: Record<string, unknown>, v: unknown): ({ page: string } | { entry: [string, string] })[] => {
|
|
118
|
+
const out: ({ page: string } | { entry: [string, string] })[] = []
|
|
119
|
+
if (element === 'u90/relation' || element === 'u90/selection')
|
|
120
|
+
for (const r of Array.isArray(v) ? v : [v])
|
|
121
|
+
if (record(r) && record(r.$ref) && typeof r.$ref.key === 'string')
|
|
122
|
+
out.push({ entry: [typeof r.$ref.collection === 'string' ? r.$ref.collection : String(options.to), r.$ref.key] })
|
|
123
|
+
if (element === 'u90/link' && record(v) && record(v.to)) {
|
|
124
|
+
const to = v.to
|
|
125
|
+
if (typeof to.page === 'string' && !isDocumentId(to.page)) out.push({ page: to.page })
|
|
126
|
+
if (record(to.entry) && typeof to.entry.collection === 'string' && typeof to.entry.key === 'string') out.push({ entry: [to.entry.collection, to.entry.key] })
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* What seeding `value` would refuse, found without seeding: each leaf's seed value against its
|
|
132
|
+
* element's seed schema, unknown fields and blocks, and page or entry keys the site does not declare.
|
|
133
|
+
* `defineSite` runs it on every declared entry, because the build cannot wait on `seed`, which
|
|
134
|
+
* imports assets. List and zone items are named by position, having no `_id` yet.
|
|
135
|
+
*/
|
|
136
|
+
export function seedIssues(schema: Schema, docDecl: DocumentDecl, value: unknown, at: string, known: SeedKnown): Issue[] {
|
|
137
|
+
const issues: Issue[] = []
|
|
138
|
+
const add = (p: string, code: string, message: string): void => { issues.push({ path: p, code, message, severity: 'error' }) }
|
|
139
|
+
const field = (decl: FieldDecl, v: unknown, here: string): void => {
|
|
140
|
+
if (v === undefined || v === null) return
|
|
141
|
+
if (isLeaf(decl)) {
|
|
142
|
+
const entry = getElement(decl.element)
|
|
143
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
144
|
+
if (!o.success) return add(here, 'invalid-options', 'The field’s declared options are invalid.')
|
|
145
|
+
const r = entry.seed(o.data).safeParse(v)
|
|
146
|
+
if (!r.success) return zodIssues(r.error).forEach((i) => add(path.child(here, ...i.path), i.code, i.message))
|
|
147
|
+
for (const k of namedKeys(decl.element, o.data as Record<string, unknown>, r.data)) {
|
|
148
|
+
if ('page' in k && !known.page(k.page)) add(here, 'unknown-page', `No page with the key "${k.page}" is declared.`)
|
|
149
|
+
if ('entry' in k && !known.entry(k.entry[0], k.entry[1])) add(here, 'unknown-entry', `No entry "${k.entry[1]}" is declared in ${k.entry[0]}.`)
|
|
150
|
+
}
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (decl.container === 'u90/group') {
|
|
154
|
+
if (!record(v)) return add(here, 'invalid_type', 'Expected an object of fields.')
|
|
155
|
+
for (const k of Object.keys(v)) if (!Object.hasOwn(decl.fields, k)) add(path.child(here, k), 'unknown-field', `No field named "${k}".`)
|
|
156
|
+
for (const [name, f] of orderedEntries(decl.fields, decl.order)) field(f, v[name], path.child(here, name))
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(v)) return add(here, 'invalid_type', decl.container === 'u90/list' ? 'Expected a list.' : 'Expected a list of blocks.')
|
|
160
|
+
if (decl.container === 'u90/list') return v.forEach((item, i) => field(decl.item, item, path.child(here, i)))
|
|
161
|
+
v.forEach((item, i) => {
|
|
162
|
+
const itemAt = path.child(here, i)
|
|
163
|
+
const block = record(item) && typeof item._block === 'string' ? item._block : ''
|
|
164
|
+
if (block === SLOT) return
|
|
165
|
+
const declared = Object.hasOwn(schema.blocks, block) ? schema.blocks[block] : undefined
|
|
166
|
+
if (!declared) return add(itemAt, 'unknown-block', `No block named "${block}".`)
|
|
167
|
+
if (!decl.allow.includes(block)) add(itemAt, 'not-allowed', `"${block}" is not allowed here.`)
|
|
168
|
+
const { _block: _b, ...rest } = item as Record<string, unknown>
|
|
169
|
+
field({ container: 'u90/group', fields: declared.fields, order: declared.order }, rest, itemAt)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (docDecl.kind === 'entry') {
|
|
173
|
+
const c = Object.hasOwn(schema.collections, docDecl.collection) ? schema.collections[docDecl.collection] : undefined
|
|
174
|
+
if (!c) return [{ path: at, code: 'unknown-collection', message: `No collection named "${docDecl.collection}".`, severity: 'error' }]
|
|
175
|
+
const v = record(value) ? value : {}
|
|
176
|
+
field({ container: 'u90/group', fields: c.fields, order: c.order }, v.fields, path.child(at, 'fields'))
|
|
177
|
+
if (c.template) field(c.template, v.blocks, path.child(at, 'blocks'))
|
|
178
|
+
} else if (docDecl.kind === 'single') {
|
|
179
|
+
const s = Object.hasOwn(schema.singles, docDecl.key) ? schema.singles[docDecl.key] : undefined
|
|
180
|
+
if (s) field(s.field, value, at)
|
|
181
|
+
} else {
|
|
182
|
+
field(docDecl.blocks, Array.isArray(value) ? value : record(value) ? value.blocks : undefined, path.child(at, 'blocks'))
|
|
183
|
+
}
|
|
184
|
+
return issues
|
|
185
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DocumentDecl, Schema } from '../define'
|
|
2
|
+
import { hasStoredShape } from '../leaf'
|
|
3
|
+
import { walkDocument } from '../walk'
|
|
4
|
+
|
|
5
|
+
/** Every leaf's text, non-empty ones joined by newlines: for search, summaries and agents. */
|
|
6
|
+
export function textOf(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string): string {
|
|
7
|
+
if ((docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) || (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key))) return ''
|
|
8
|
+
const parts: string[] = []
|
|
9
|
+
walkDocument(schema, docDecl, doc, {
|
|
10
|
+
leaf: ({ entry, decl, value }) => {
|
|
11
|
+
if (value === undefined || value === null) return
|
|
12
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
13
|
+
if (!o.success) return
|
|
14
|
+
if (!hasStoredShape(entry, o.data, value)) return
|
|
15
|
+
const t = entry.toText(value, o.data)
|
|
16
|
+
if (t) parts.push(t)
|
|
17
|
+
},
|
|
18
|
+
}, at)
|
|
19
|
+
return parts.join('\n')
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DocumentDecl, Schema } from '../define'
|
|
2
|
+
import { hasStoredShape } from '../leaf'
|
|
3
|
+
import { path } from '../path'
|
|
4
|
+
import { walkDocument } from '../walk'
|
|
5
|
+
|
|
6
|
+
export function translatableParts(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string): { path: string; value: string }[] {
|
|
7
|
+
if ((docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) || (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key))) return []
|
|
8
|
+
const out: { path: string; value: string }[] = []
|
|
9
|
+
walkDocument(schema, docDecl, doc, {
|
|
10
|
+
leaf: ({ entry, decl, value, at: here }) => {
|
|
11
|
+
if (value === undefined || value === null) return
|
|
12
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
13
|
+
if (!o.success) return
|
|
14
|
+
if (!hasStoredShape(entry, o.data, value)) return
|
|
15
|
+
for (const part of entry.translatable(value, o.data)) out.push({ path: path.child(here, ...part.path), value: part.value })
|
|
16
|
+
},
|
|
17
|
+
}, at)
|
|
18
|
+
return out
|
|
19
|
+
}
|