@unitninety/site-kit 0.0.0-stage → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,155 @@
1
+ import { z } from 'zod'
2
+ import { defineElement, requiredIssue, type ExpandCtx, type Ref } from '../define'
3
+ import { TOKEN, textOfRead, tokenKeys } from '../tokens'
4
+ import { LinkTo, hrefOf } from './link'
5
+
6
+ /** Lexical's inline format bits — the same values `lexical.ts` exports; restated so this module has no runtime import. */
7
+ const BITS = { bold: 1, italic: 2, strikethrough: 4, underline: 8, code: 16 } as const
8
+ const MARKS = ['bold', 'italic', 'underline', 'strikethrough', 'code', 'link'] as const
9
+ const BLOCKS = ['paragraph', 'heading', 'list', 'quote'] as const
10
+ const HEADINGS = ['h2', 'h3', 'h4'] as const
11
+
12
+ export const RichTextOptions = z.strictObject({
13
+ inline: z.boolean().default(false),
14
+ blocks: z.array(z.enum(BLOCKS)).min(1).default(['paragraph', 'heading', 'list']),
15
+ headings: z.array(z.enum(HEADINGS)).min(1).default(['h2', 'h3']),
16
+ marks: z.array(z.enum(MARKS)).default(['bold', 'italic', 'underline', 'link']),
17
+ })
18
+ type O = z.infer<typeof RichTextOptions>
19
+
20
+ const common = { direction: z.enum(['ltr', 'rtl']).nullable().default('ltr'), format: z.string().default(''), indent: z.int().default(0), version: z.int().default(1) }
21
+ const TextNode = z.object({ type: z.literal('text'), text: z.string(), format: z.int().nonnegative().default(0), detail: z.int().default(0), mode: z.string().default('normal'), style: z.string().default('') , version: z.int().default(1) })
22
+ const LineBreak = z.object({ type: z.literal('linebreak'), version: z.int().default(1) })
23
+ const LinkNode = z.object({ type: z.literal('link'), fields: z.object({ to: LinkTo, newTab: z.boolean().default(false) }), children: z.array(z.union([TextNode, LineBreak])), ...common })
24
+ const inlines = (o: O) => z.array(o.marks.includes('link') ? z.union([TextNode, LineBreak, LinkNode]) : z.union([TextNode, LineBreak]))
25
+ const Paragraph = (o: O) => z.object({ type: z.literal('paragraph'), children: inlines(o), textFormat: z.int().default(0), ...common })
26
+ const Heading = (o: O) => z.object({ type: z.literal('heading'), tag: z.enum(o.headings as [string, ...string[]]), children: inlines(o), ...common })
27
+ const Quote = (o: O) => z.object({ type: z.literal('quote'), children: inlines(o), ...common })
28
+ const ListItem = (o: O) => z.object({ type: z.literal('listitem'), children: inlines(o), value: z.int().default(1), ...common })
29
+ const List = (o: O) => z.object({ type: z.literal('list'), listType: z.enum(['bullet', 'number']), tag: z.enum(['ul', 'ol']), start: z.int().default(1), children: z.array(ListItem(o)), ...common })
30
+ const blockSchemas = (o: O) => {
31
+ const by = { paragraph: Paragraph(o), heading: Heading(o), list: List(o), quote: Quote(o) }
32
+ const chosen = o.blocks.map((b) => by[b])
33
+ return chosen.length === 1 ? chosen[0] : z.union(chosen as z.ZodTypeAny[] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]])
34
+ }
35
+ const allowedMask = (o: O) => o.marks.reduce((m, k) => (k === 'link' ? m : m | BITS[k]), 0)
36
+
37
+ type Node = { type: string; text?: string; format?: number | string; children?: Node[]; fields?: Record<string, unknown> } & Record<string, unknown>
38
+ const eachText = (node: Node, path: (string | number)[], f: (n: Node, path: (string | number)[]) => void) => {
39
+ if (node.type === 'text') f(node, path)
40
+ node.children?.forEach((c, i) => eachText(c, [...path, 'children', i], f))
41
+ }
42
+ const eachLink = (node: Node, f: (n: Node) => void) => {
43
+ if (node.type === 'link') f(node)
44
+ node.children?.forEach((c) => eachLink(c, f))
45
+ }
46
+
47
+ const stored = (o: O) =>
48
+ z
49
+ .object({ root: z.object({ type: z.literal('root'), children: z.array(blockSchemas(o)), ...common }) })
50
+ .superRefine((v, ctx) => {
51
+ const root = v.root as unknown as Node
52
+ if (o.inline && (root.children!.length !== 1 || root.children![0].type !== 'paragraph'))
53
+ ctx.addIssue({ code: 'custom', path: ['root', 'children'], message: 'Inline text is a single paragraph.', params: { code: 'inline' } })
54
+ const mask = allowedMask(o)
55
+ eachText(root, ['root'], (n, path) => {
56
+ if (typeof n.format === 'number' && (n.format & ~mask) !== 0)
57
+ ctx.addIssue({ code: 'custom', path: [...path, 'format'], message: `Only these marks are allowed: ${o.marks.join(', ')}.`, params: { code: 'marks' } })
58
+ })
59
+ })
60
+ .nullable()
61
+ type Stored = z.infer<ReturnType<typeof stored>>
62
+ /** The widest options: every block, and a link in every inline run. */
63
+ const WIDE: O = { inline: false, blocks: [...BLOCKS], headings: [...HEADINGS], marks: [...MARKS] }
64
+ /** The same tree with all four blocks, any heading tag and a link anywhere inline, so narrowed options never blank content. */
65
+ const SHAPE = z
66
+ .object({ root: z.object({ type: z.literal('root'), children: z.array(z.union([Paragraph(WIDE), Heading(WIDE).extend({ tag: z.string() }), List(WIDE), Quote(WIDE)])), ...common }) })
67
+ .nullable()
68
+
69
+ export const richTextToPlain = (v: Stored): string =>
70
+ v ? (v.root.children as unknown as Node[]).map((b) => { const parts: string[] = []; eachText(b, [], (n) => parts.push(n.text ?? '')); return parts.join('') }).join('\n') : ''
71
+
72
+ export type RichTextRead = ({ root: unknown; $path?: string }) | null
73
+ const expand = (v: Stored, o: O, ctx: ExpandCtx): RichTextRead => {
74
+ if (v === null) return null
75
+ const root = structuredClone(v.root) as unknown as Node
76
+ eachLink(root, (n) => { const fields = n.fields as { to: z.infer<typeof LinkTo> }; Object.assign(fields, hrefOf(fields.to, ctx)) })
77
+ const split = (node: Node) => {
78
+ if (!node.children) return
79
+ node.children.forEach(split)
80
+ node.children = node.children.flatMap((c) => {
81
+ if (c.type !== 'text' || typeof c.text !== 'string' || !TOKEN.test(c.text)) return [c]
82
+ TOKEN.lastIndex = 0
83
+ const out: Node[] = []
84
+ let last = 0
85
+ for (const m of c.text.matchAll(TOKEN)) {
86
+ if (m.index! > last) out.push({ ...c, text: c.text.slice(last, m.index) })
87
+ const resolved = textOfRead(ctx.single(m[1]))
88
+ out.push(resolved === undefined ? { ...c, text: m[0] } : { type: 'single', key: m[1], text: resolved, format: c.format ?? 0, version: 1 })
89
+ last = m.index! + m[0].length
90
+ }
91
+ if (last < c.text.length) out.push({ ...c, text: c.text.slice(last) })
92
+ return out
93
+ })
94
+ }
95
+ split(root)
96
+ return { root, ...(ctx.draft ? { $path: ctx.at } : {}) }
97
+ }
98
+
99
+ const linkNodes = (v: NonNullable<Stored>): Node[] => { const out: Node[] = []; eachLink(v.root as unknown as Node, (n) => out.push(n)); return out }
100
+ const tokensOf = (v: NonNullable<Stored>): string => { const keys: string[] = []; eachText(v.root as unknown as Node, [], (n) => keys.push(...tokenKeys(n.text ?? ''))); return keys.sort().join(',') }
101
+ /**
102
+ * A translation may reword and reformat, never repoint: it lands only when it holds as many links as the
103
+ * base and the same `{single}` tokens, and each link keeps the base link's fields (destination, new tab).
104
+ */
105
+ const landTranslation = (base: Stored | undefined, t: Stored): Stored | undefined => {
106
+ if (!base || !t) return undefined
107
+ const from = linkNodes(base)
108
+ const copy = structuredClone(t)
109
+ const into = linkNodes(copy)
110
+ if (from.length !== into.length || tokensOf(base) !== tokensOf(copy)) return undefined
111
+ from.forEach((n, i) => { into[i].fields = structuredClone(n.fields) })
112
+ return copy
113
+ }
114
+
115
+ export const richText = defineElement({
116
+ id: 'u90/rich-text',
117
+ version: 1,
118
+ label: 'Rich text',
119
+ placement: 'any',
120
+ options: RichTextOptions,
121
+ seed: stored,
122
+ stored,
123
+ shape: () => SHAPE,
124
+ read: () => z.object({ root: z.unknown(), $path: z.string().optional() }).nullable(),
125
+ complete: (v, _o, field) => requiredIssue(richTextToPlain(v ?? null).trim() !== '', field),
126
+ refs: (v) => {
127
+ if (!v) return []
128
+ const refs: Ref[] = []
129
+ const root = v.root as unknown as Node
130
+ eachText(root, [], (n) => tokenKeys(n.text ?? '').forEach((key) => refs.push({ kind: 'single', key })))
131
+ eachLink(root, (n) => {
132
+ const to = (n.fields as { to: z.infer<typeof LinkTo> }).to
133
+ if ('page' in to) refs.push({ kind: 'page', id: to.page })
134
+ else if ('entry' in to) refs.push({ kind: 'entry', collection: to.entry.collection, id: to.entry.id, depth: 0 })
135
+ })
136
+ return refs
137
+ },
138
+ toStored: async (seed) => seed,
139
+ expand,
140
+ expandSeed: expand,
141
+ toText: richTextToPlain,
142
+ translatable: (v) => {
143
+ const out: { path: (string | number)[]; value: string }[] = []
144
+ if (v) eachText(v.root as unknown as Node, ['root'], (n, path) => { if (n.text) out.push({ path: [...path, 'text'], value: n.text }) })
145
+ return out
146
+ },
147
+ translation: {
148
+ applies: () => true,
149
+ value: (o) => stored(o),
150
+ pick: (v) => v,
151
+ apply: (base, t) => landTranslation(base, t),
152
+ },
153
+ empty: () => null,
154
+ upgrade: {},
155
+ })
@@ -0,0 +1,40 @@
1
+ import { z, type ZodType } from 'zod'
2
+ import { EntryReadSchema, Id, defineElement, requiredIssue, type EntryRead, type ExpandCtx, type Ref } from '../define'
3
+ import { EntryRef, resolveEntryRef, uniqueIds } from './relation'
4
+
5
+ export const SelectionOptions = z.strictObject({ to: z.string().min(1), many: z.boolean().default(false), max: z.int().positive().optional(), depth: z.int().min(0).max(3).default(1) })
6
+ type O = z.infer<typeof SelectionOptions>
7
+ /** How a selection and a query read: the entries in order, how many there are, and `$path` in draft. */
8
+ export type EntriesRead = { entries: EntryRead[]; total: number; $path?: string; $pins?: string[] }
9
+ export const EntriesReadSchema = z.object({ entries: z.array(EntryReadSchema), total: z.number(), $path: z.string().optional(), $pins: z.array(z.string()).optional() })
10
+
11
+ const stored = (o: O) => (o.many ? (o.max ? z.array(Id).max(o.max) : z.array(Id)).superRefine(uniqueIds) : Id.nullable())
12
+ type SeedValue = z.infer<typeof EntryRef> | z.infer<typeof EntryRef>[] | null
13
+ const seed = (o: O): ZodType<SeedValue> => (o.many ? z.array(EntryRef) : EntryRef.nullable())
14
+ const ids = (v: Id | Id[] | null) => (Array.isArray(v) ? v : v === null ? [] : [v])
15
+ /** Every selected entry is read, so `total` is the number of entries — the same shape a query reads. */
16
+ const expand = (v: Id | Id[] | null, o: O, ctx: ExpandCtx): EntriesRead => {
17
+ const entries = ids(v).map((id) => ctx.entry(o.to, id, o.depth))
18
+ return { entries, total: entries.length, ...(ctx.draft ? { $path: ctx.at } : {}) }
19
+ }
20
+
21
+ export const selection = defineElement({
22
+ id: 'u90/selection',
23
+ version: 1,
24
+ label: 'Selection',
25
+ placement: 'content',
26
+ options: SelectionOptions,
27
+ seed,
28
+ stored,
29
+ read: () => EntriesReadSchema,
30
+ complete: (v, _o, field) => requiredIssue(ids(v ?? null).length > 0, field),
31
+ refs: (v, o): Ref[] => ids(v).map((id) => ({ kind: 'entry', collection: o.to, id, depth: o.depth })),
32
+ toStored: async (s, o, ctx) => (Array.isArray(s) ? s.map((r) => resolveEntryRef(r, o.to, ctx)) : s === null ? null : resolveEntryRef(s, o.to, ctx)),
33
+ expand,
34
+ expandSeed: (s, o, ctx) => expand(Array.isArray(s) ? (s.filter((r) => typeof r !== 'object') as Id[]) : typeof s === 'object' ? null : s, o, ctx),
35
+ toText: () => '',
36
+ translatable: () => [],
37
+ translation: null,
38
+ empty: (o) => (o.many ? [] : null),
39
+ upgrade: {},
40
+ })
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod'
2
+ import { defineElement } from '../define'
3
+
4
+ export const SingleOptions = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/) })
5
+ type O = z.infer<typeof SingleOptions>
6
+ /** Nothing is stored on the block: the declaration fixes the key. Only `null` (or absence) is valid. */
7
+ const stored = z.null()
8
+
9
+ export const single = defineElement({
10
+ id: 'u90/single',
11
+ version: 1,
12
+ label: 'Single',
13
+ placement: 'any',
14
+ options: SingleOptions,
15
+ seed: () => stored,
16
+ stored: () => stored,
17
+ read: () => z.unknown(),
18
+ /** `validateSchema` rejects a key that names no Single; whether the Single has a value is not checked by the registry — it needs data this leaf does not have. */
19
+ complete: () => [],
20
+ refs: (_v, o) => [{ kind: 'single', key: o.key }],
21
+ toStored: async () => null,
22
+ expand: (_v, o, ctx) => ctx.single(o.key) ?? null,
23
+ expandSeed: (_v, o, ctx) => ctx.single(o.key) ?? null,
24
+ toText: () => '',
25
+ translatable: () => [],
26
+ translation: null,
27
+ empty: () => null,
28
+ upgrade: {},
29
+ })
@@ -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
+ }