@unitninety/site-kit 0.0.0-stage → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,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
|
+
}
|