@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.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. 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
+ }