@unitninety/site-kit 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Id, TEL_RE, URL_RE, defineElement, requiredIssue, type ExpandCtx, type SeedCtx } from '../define'
|
|
3
|
+
import { isDocumentId } from '../ids'
|
|
4
|
+
|
|
5
|
+
const ANCHOR = z.string().regex(/^[A-Za-z][\w-]*$/)
|
|
6
|
+
const KINDS = ['page', 'entry', 'url', 'email', 'tel'] as const
|
|
7
|
+
export type LinkKind = (typeof KINDS)[number]
|
|
8
|
+
|
|
9
|
+
export const LinkTo = z.union([
|
|
10
|
+
z.strictObject({ page: Id, anchor: ANCHOR.optional() }),
|
|
11
|
+
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
12
|
+
z.strictObject({ url: z.string().regex(URL_RE, 'Expected an absolute URL or a root-relative path.') }),
|
|
13
|
+
z.strictObject({ email: z.email() }),
|
|
14
|
+
z.strictObject({ tel: z.string().regex(TEL_RE, 'Expected a phone number.') }),
|
|
15
|
+
])
|
|
16
|
+
export type LinkTo = z.infer<typeof LinkTo>
|
|
17
|
+
/** Seeds may name pages and entries by key instead of id. */
|
|
18
|
+
const LinkToSeed = z.union([
|
|
19
|
+
z.strictObject({ page: z.union([Id, z.string().min(1)]), anchor: ANCHOR.optional() }),
|
|
20
|
+
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), key: z.string().min(1) }) }),
|
|
21
|
+
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
22
|
+
z.strictObject({ url: z.string().regex(URL_RE) }),
|
|
23
|
+
z.strictObject({ email: z.email() }),
|
|
24
|
+
z.strictObject({ tel: z.string().regex(TEL_RE) }),
|
|
25
|
+
])
|
|
26
|
+
type SeedTo = z.infer<typeof LinkToSeed>
|
|
27
|
+
|
|
28
|
+
export const LinkOptions = z.strictObject({ allow: z.array(z.enum(KINDS)).min(1).default([...KINDS]) })
|
|
29
|
+
type O = z.infer<typeof LinkOptions>
|
|
30
|
+
export const linkKind = (to: LinkTo): LinkKind => KINDS.find((k) => k in to)!
|
|
31
|
+
|
|
32
|
+
const stored = (o: O) =>
|
|
33
|
+
z
|
|
34
|
+
.strictObject({
|
|
35
|
+
label: z.string(),
|
|
36
|
+
to: LinkTo.nullable(),
|
|
37
|
+
newTab: z.boolean(),
|
|
38
|
+
})
|
|
39
|
+
.superRefine((v, ctx) => {
|
|
40
|
+
if (v.to && !o.allow.includes(linkKind(v.to)))
|
|
41
|
+
ctx.addIssue({ code: 'custom', path: ['to'], message: `This link may only point at: ${o.allow.join(', ')}.`, params: { code: 'not-allowed' } })
|
|
42
|
+
})
|
|
43
|
+
.nullable()
|
|
44
|
+
type Stored = z.infer<ReturnType<typeof stored>>
|
|
45
|
+
const seed = () =>
|
|
46
|
+
z.strictObject({ label: z.string().default(''), to: LinkToSeed.nullable().default(null), newTab: z.boolean().default(false) }).nullable()
|
|
47
|
+
type SeedValue = z.infer<ReturnType<typeof seed>>
|
|
48
|
+
|
|
49
|
+
export type LinkRead = { label: string; href: string | null; newTab: boolean; to: LinkTo | null; status: 'ready' | 'missing'; $path?: string } | null
|
|
50
|
+
const read = z
|
|
51
|
+
.object({ label: z.string(), href: z.string().nullable(), newTab: z.boolean(), to: LinkTo.nullable(), status: z.enum(['ready', 'missing']), $path: z.string().optional() })
|
|
52
|
+
.nullable()
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The site query parameter that carries a language in draft (the Canvas's translated frames). Outside draft a site ignores it.
|
|
56
|
+
* An internal href keeps it, so following a link inside a translated frame stays in that language.
|
|
57
|
+
*/
|
|
58
|
+
export const DRAFT_LOCALE_PARAM = 'u90-locale'
|
|
59
|
+
/** An href on this site: root-relative, and not `//host`. The same rule as the `Link` component's `isInternal`. */
|
|
60
|
+
const internal = (href: string) => href.startsWith('/') && !href.startsWith('//')
|
|
61
|
+
/** `href` with `DRAFT_LOCALE_PARAM=<locale>` in its query, after any query it already has and before its fragment. A value it already carries is replaced. */
|
|
62
|
+
export const withDraftLocale = (href: string, locale: string): string => {
|
|
63
|
+
const hashAt = href.indexOf('#')
|
|
64
|
+
const hash = hashAt < 0 ? '' : href.slice(hashAt)
|
|
65
|
+
const rest = hashAt < 0 ? href : href.slice(0, hashAt)
|
|
66
|
+
const queryAt = rest.indexOf('?')
|
|
67
|
+
const base = queryAt < 0 ? rest : rest.slice(0, queryAt)
|
|
68
|
+
const pairs = queryAt < 0 ? [] : rest.slice(queryAt + 1).split('&').filter((p) => p !== '' && p.split('=')[0] !== DRAFT_LOCALE_PARAM)
|
|
69
|
+
return `${base}?${[...pairs, `${DRAFT_LOCALE_PARAM}=${encodeURIComponent(locale)}`].join('&')}${hash}`
|
|
70
|
+
}
|
|
71
|
+
/** In draft with a `draftLocale`, an internal href keeps the language; every other href, and every href outside draft, is unchanged. */
|
|
72
|
+
export const hrefOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
73
|
+
const r = targetOf(to, ctx)
|
|
74
|
+
return ctx.draftLocale && r.href && internal(r.href) ? { ...r, href: withDraftLocale(r.href, ctx.draftLocale) } : r
|
|
75
|
+
}
|
|
76
|
+
const targetOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
77
|
+
if (!to) return { href: null, status: 'ready' }
|
|
78
|
+
if ('page' in to) {
|
|
79
|
+
const p = ctx.page(to.page)
|
|
80
|
+
return p ? { href: `${p.route}${to.anchor ? `#${to.anchor}` : ''}`, status: 'ready' } : { href: null, status: 'missing' }
|
|
81
|
+
}
|
|
82
|
+
if ('entry' in to) {
|
|
83
|
+
const e = ctx.entry(to.entry.collection, to.entry.id, 0)
|
|
84
|
+
return e.status === 'ready' && e.route ? { href: e.route, status: 'ready' } : { href: null, status: 'missing' }
|
|
85
|
+
}
|
|
86
|
+
if ('url' in to) return { href: to.url, status: 'ready' }
|
|
87
|
+
if ('email' in to) return { href: `mailto:${to.email}`, status: 'ready' }
|
|
88
|
+
return { href: `tel:${to.tel.replace(/[^+\d]/g, '')}`, status: 'ready' }
|
|
89
|
+
}
|
|
90
|
+
const expand = (v: Stored, _o: O, ctx: ExpandCtx): LinkRead =>
|
|
91
|
+
v === null ? null : { label: v.label, ...hrefOf(v.to, ctx), newTab: v.newTab, to: v.to, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A seed `to` that names a page or an entry by key rather than id. `ExpandCtx` deliberately has no key lookup
|
|
95
|
+
* (seeding is what resolves keys), so this can never be turned into an href — page and entry keys are treated
|
|
96
|
+
* the same way here, both unresolvable until `toStored` runs.
|
|
97
|
+
*/
|
|
98
|
+
const isUnresolvedSeedRef = (to: SeedTo | null): boolean =>
|
|
99
|
+
!!to && (('page' in to && typeof to.page === 'string' && !isDocumentId(to.page)) || ('entry' in to && 'key' in to.entry))
|
|
100
|
+
|
|
101
|
+
const expandSeed = (s: SeedValue, o: O, ctx: ExpandCtx): LinkRead => {
|
|
102
|
+
if (s === null) return null
|
|
103
|
+
if (isUnresolvedSeedRef(s.to)) return { label: s.label, href: null, newTab: s.newTab, to: null, status: 'missing', ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
104
|
+
return expand({ label: s.label, to: s.to as LinkTo | null, newTab: s.newTab }, o, ctx)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const link = defineElement({
|
|
108
|
+
id: 'u90/link',
|
|
109
|
+
version: 1,
|
|
110
|
+
label: 'Link',
|
|
111
|
+
placement: 'any',
|
|
112
|
+
options: LinkOptions,
|
|
113
|
+
seed,
|
|
114
|
+
stored,
|
|
115
|
+
read: () => read,
|
|
116
|
+
complete: (v, _o, field) => {
|
|
117
|
+
if (v == null) return requiredIssue(false, field)
|
|
118
|
+
const issues = []
|
|
119
|
+
if (!v.to && field.required) issues.push({ path: ['to'], code: 'required', message: 'Choose where the link goes.' })
|
|
120
|
+
if (!v.label.trim()) issues.push({ path: ['label'], code: 'blank', message: 'The link needs words.' })
|
|
121
|
+
return issues
|
|
122
|
+
},
|
|
123
|
+
refs: (v) => {
|
|
124
|
+
if (!v?.to) return []
|
|
125
|
+
if ('page' in v.to) return [{ kind: 'page', id: v.to.page }]
|
|
126
|
+
if ('entry' in v.to) return [{ kind: 'entry', collection: v.to.entry.collection, id: v.to.entry.id, depth: 0 }]
|
|
127
|
+
return []
|
|
128
|
+
},
|
|
129
|
+
toStored: async (s, _o, ctx: SeedCtx) => {
|
|
130
|
+
if (s === null) return null
|
|
131
|
+
let to: LinkTo | null = null
|
|
132
|
+
if (s.to && 'page' in s.to) to = { page: typeof s.to.page === 'string' && !isDocumentId(s.to.page) ? ctx.resolvePage(s.to.page) : s.to.page, ...(s.to.anchor ? { anchor: s.to.anchor } : {}) }
|
|
133
|
+
else if (s.to && 'entry' in s.to) to = { entry: { collection: s.to.entry.collection, id: 'key' in s.to.entry ? ctx.resolveEntry(s.to.entry.collection, s.to.entry.key) : s.to.entry.id } }
|
|
134
|
+
else to = s.to
|
|
135
|
+
return { label: s.label, to, newTab: s.newTab }
|
|
136
|
+
},
|
|
137
|
+
expand,
|
|
138
|
+
expandSeed,
|
|
139
|
+
toText: (v) => v?.label ?? '',
|
|
140
|
+
translatable: (v) => (v && v.label ? [{ path: ['label'], value: v.label }] : []),
|
|
141
|
+
translation: {
|
|
142
|
+
applies: () => true,
|
|
143
|
+
value: () => z.object({ label: z.string().min(1) }),
|
|
144
|
+
pick: (v) => (v?.label ? { label: v.label } : null),
|
|
145
|
+
apply: (base, t) => (base ? { ...base, label: t.label } : undefined),
|
|
146
|
+
},
|
|
147
|
+
empty: () => null,
|
|
148
|
+
upgrade: {},
|
|
149
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { defineElement, requiredIssue } from '../define'
|
|
3
|
+
|
|
4
|
+
export const NumberOptions = z.strictObject({
|
|
5
|
+
integer: z.boolean().default(false),
|
|
6
|
+
min: z.number().optional(),
|
|
7
|
+
max: z.number().optional(),
|
|
8
|
+
step: z.number().positive().optional(),
|
|
9
|
+
})
|
|
10
|
+
type O = z.infer<typeof NumberOptions>
|
|
11
|
+
export type NumberRead = { value: number; $path?: string } | null
|
|
12
|
+
|
|
13
|
+
const stored = (o: O) => {
|
|
14
|
+
let s = o.integer ? z.int() : z.number()
|
|
15
|
+
if (o.min !== undefined) s = s.min(o.min)
|
|
16
|
+
if (o.max !== undefined) s = s.max(o.max)
|
|
17
|
+
return s
|
|
18
|
+
.superRefine((v, ctx) => {
|
|
19
|
+
if (o.step === undefined) return
|
|
20
|
+
const offset = (v - (o.min ?? 0)) / o.step
|
|
21
|
+
if (Math.abs(offset - Math.round(offset)) > 1e-9)
|
|
22
|
+
ctx.addIssue({ code: 'custom', message: `Must be a multiple of ${o.step}${o.min !== undefined ? ` from ${o.min}` : ''}.`, params: { code: 'step' } })
|
|
23
|
+
})
|
|
24
|
+
.nullable()
|
|
25
|
+
}
|
|
26
|
+
/** Any number: `integer`, `min`, `max` and `step` are rules, so a value that breaks one still reads. */
|
|
27
|
+
const SHAPE = z.number().nullable()
|
|
28
|
+
const read = z.object({ value: z.number(), $path: z.string().optional() }).nullable()
|
|
29
|
+
const expand = (v: number | null, _o: O, ctx: { at: string; draft: boolean }): NumberRead =>
|
|
30
|
+
v === null ? null : { value: v, ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
31
|
+
|
|
32
|
+
export const number = defineElement({
|
|
33
|
+
id: 'u90/number',
|
|
34
|
+
version: 1,
|
|
35
|
+
label: 'Number',
|
|
36
|
+
placement: 'any',
|
|
37
|
+
options: NumberOptions,
|
|
38
|
+
seed: stored,
|
|
39
|
+
stored,
|
|
40
|
+
shape: () => SHAPE,
|
|
41
|
+
read: () => read,
|
|
42
|
+
complete: (v, _o, field) => requiredIssue(typeof v === 'number', field),
|
|
43
|
+
refs: () => [],
|
|
44
|
+
toStored: async (seed) => seed,
|
|
45
|
+
expand,
|
|
46
|
+
expandSeed: expand,
|
|
47
|
+
toText: (v) => (v === null ? '' : String(v)),
|
|
48
|
+
translatable: () => [],
|
|
49
|
+
translation: null,
|
|
50
|
+
empty: () => null,
|
|
51
|
+
upgrade: {},
|
|
52
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { Id, defineElement, type ExpandCtx, type Ref } from '../define'
|
|
3
|
+
import { ELEMENT_IDS, type ElementId } from '../ids'
|
|
4
|
+
import { EntriesReadSchema, type EntriesRead } from './selection'
|
|
5
|
+
|
|
6
|
+
export const FILTERABLE: readonly ElementId[] = ['u90/choice', 'u90/boolean', 'u90/relation', 'u90/date', 'u90/number']
|
|
7
|
+
export const SORTABLE: readonly ElementId[] = ['u90/text', 'u90/date', 'u90/number']
|
|
8
|
+
const OPS = ['eq', 'neq', 'in', 'not-in', 'is-set'] as const
|
|
9
|
+
|
|
10
|
+
export const QueryOptions = z.strictObject({
|
|
11
|
+
to: z.string().min(1),
|
|
12
|
+
/** field → element of the target collection; filled in by validateSchema from the CollectionDecl. */
|
|
13
|
+
targetFields: z.record(z.string(), z.enum(ELEMENT_IDS)).optional(),
|
|
14
|
+
})
|
|
15
|
+
type O = z.infer<typeof QueryOptions>
|
|
16
|
+
const Scalar = z.union([z.string(), z.number(), z.boolean()])
|
|
17
|
+
export const QueryStored = z.strictObject({
|
|
18
|
+
where: z.array(z.strictObject({ field: z.string().min(1), op: z.enum(OPS), value: z.union([Scalar, z.array(Scalar)]).optional() })).optional(),
|
|
19
|
+
sort: z.union([z.strictObject({ field: z.string().min(1), dir: z.enum(['asc', 'desc']) }), z.literal('manual')]).optional(),
|
|
20
|
+
limit: z.int().min(1).max(100).optional(),
|
|
21
|
+
/** Entries the rule returns that this placement leaves out. */
|
|
22
|
+
exclude: z.array(Id).optional(),
|
|
23
|
+
/** Entries this placement adds although the rule does not return them. */
|
|
24
|
+
pin: z.array(Id).optional(),
|
|
25
|
+
/** A hand-set order: these entries first, in this order, then the rest in the rule's own order. */
|
|
26
|
+
order: z.array(Id).optional(),
|
|
27
|
+
})
|
|
28
|
+
export type QueryStored = z.infer<typeof QueryStored>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A query's own picks applied to the ids its rule returned, in the rule's order: excluded entries
|
|
32
|
+
* leave, `limit` cuts what remains of the rule's own results, pinned entries not among those kept
|
|
33
|
+
* join at the end (an id both excluded and pinned stays out), and a hand-set `order` puts its
|
|
34
|
+
* entries first. So a pin survives the limit. `total` counts the rule's matches after exclusions
|
|
35
|
+
* plus the pins not among them, before `limit`. The platform and the fake both call this, so a
|
|
36
|
+
* placement reads the same everywhere. `exists` says whether a pinned id names an entry the read
|
|
37
|
+
* may show.
|
|
38
|
+
*/
|
|
39
|
+
export const applyQueryPicks = (ids: Id[], q: QueryStored, exists: (id: Id) => boolean): { ids: Id[]; total: number } => {
|
|
40
|
+
const excluded = new Set((q.exclude ?? []).map(String))
|
|
41
|
+
const matched = ids.filter((id) => !excluded.has(String(id)))
|
|
42
|
+
const matches = new Set(matched.map(String))
|
|
43
|
+
const out = q.limit ? matched.slice(0, q.limit) : [...matched]
|
|
44
|
+
const present = new Map(out.map((id) => [String(id), id]))
|
|
45
|
+
let pinsBeyond = 0
|
|
46
|
+
for (const id of q.pin ?? [])
|
|
47
|
+
if (!present.has(String(id)) && !excluded.has(String(id)) && exists(id)) {
|
|
48
|
+
out.push(id)
|
|
49
|
+
present.set(String(id), id)
|
|
50
|
+
if (!matches.has(String(id))) pinsBeyond++
|
|
51
|
+
}
|
|
52
|
+
const total = matched.length + pinsBeyond
|
|
53
|
+
if (!q.order?.length) return { ids: out, total }
|
|
54
|
+
const first = q.order.filter((id) => present.has(String(id))).map((id) => present.get(String(id))!)
|
|
55
|
+
const leading = new Set(first.map(String))
|
|
56
|
+
return { ids: [...first, ...out.filter((id) => !leading.has(String(id)))], total }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const stored = (o: O) =>
|
|
60
|
+
QueryStored.superRefine((q, ctx) => {
|
|
61
|
+
const fields = o.targetFields
|
|
62
|
+
q.where?.forEach((w, i) => {
|
|
63
|
+
const el = fields?.[w.field]
|
|
64
|
+
if (fields && !el) return ctx.addIssue({ code: 'custom', path: ['where', i, 'field'], message: `The target has no field "${w.field}".`, params: { code: 'unknown-field' } })
|
|
65
|
+
if (el && !FILTERABLE.includes(el)) return ctx.addIssue({ code: 'custom', path: ['where', i, 'field'], message: 'Only choice, boolean, relation, date and number fields can be filtered.', params: { code: 'not-filterable' } })
|
|
66
|
+
if ((w.op === 'in' || w.op === 'not-in') && !Array.isArray(w.value)) ctx.addIssue({ code: 'invalid_type', path: ['where', i, 'value'], message: 'in and not-in take a list of values.', expected: 'array', input: w.value } as never)
|
|
67
|
+
if ((w.op === 'eq' || w.op === 'neq') && (w.value === undefined || Array.isArray(w.value))) ctx.addIssue({ code: 'invalid_type', path: ['where', i, 'value'], message: 'eq and neq take one value.', expected: 'string', input: w.value } as never)
|
|
68
|
+
})
|
|
69
|
+
if (q.sort && q.sort !== 'manual') {
|
|
70
|
+
const el = fields?.[q.sort.field]
|
|
71
|
+
if (fields && !el) ctx.addIssue({ code: 'custom', path: ['sort', 'field'], message: `The target has no field "${q.sort.field}".`, params: { code: 'unknown-field' } })
|
|
72
|
+
else if (el && !SORTABLE.includes(el)) ctx.addIssue({ code: 'custom', path: ['sort', 'field'], message: 'Only text, date and number fields can be sorted.', params: { code: 'not-sortable' } })
|
|
73
|
+
}
|
|
74
|
+
for (const k of ['exclude', 'pin', 'order'] as const) {
|
|
75
|
+
const ids = q[k]
|
|
76
|
+
if (ids && new Set(ids.map(String)).size !== ids.length)
|
|
77
|
+
ctx.addIssue({ code: 'custom', path: [k], message: 'Each entry may be listed once.', params: { code: 'duplicate' } })
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
const expand = (v: QueryStored, o: O, ctx: ExpandCtx): EntriesRead => ({ ...ctx.query(o.to, v), ...(ctx.draft ? { $path: ctx.at } : {}) })
|
|
81
|
+
|
|
82
|
+
export const query = defineElement({
|
|
83
|
+
id: 'u90/query',
|
|
84
|
+
version: 1,
|
|
85
|
+
label: 'Query',
|
|
86
|
+
placement: 'content',
|
|
87
|
+
options: QueryOptions,
|
|
88
|
+
seed: stored,
|
|
89
|
+
stored,
|
|
90
|
+
read: () => EntriesReadSchema,
|
|
91
|
+
complete: () => [],
|
|
92
|
+
refs: (v, o): Ref[] => [{ kind: 'query', collection: o.to, query: v }],
|
|
93
|
+
toStored: async (s) => s,
|
|
94
|
+
expand,
|
|
95
|
+
expandSeed: expand,
|
|
96
|
+
toText: () => '',
|
|
97
|
+
translatable: () => [],
|
|
98
|
+
translation: null,
|
|
99
|
+
empty: () => ({}),
|
|
100
|
+
upgrade: {},
|
|
101
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z, type ZodType } from 'zod'
|
|
2
|
+
import { EntryReadSchema, Id, defineElement, requiredIssue, type EntryRead, type ExpandCtx, type Ref, type SeedCtx } from '../define'
|
|
3
|
+
import { path } from '../path'
|
|
4
|
+
|
|
5
|
+
/** Seeds name entries by key inside the target collection, or give an id. */
|
|
6
|
+
export const EntryRef = z.union([Id, z.strictObject({ $ref: z.strictObject({ collection: z.string().min(1).optional(), key: z.string().min(1) }) })])
|
|
7
|
+
export const resolveEntryRef = (ref: z.infer<typeof EntryRef>, collection: string, ctx: SeedCtx) =>
|
|
8
|
+
typeof ref === 'object' ? ctx.resolveEntry(ref.$ref.collection ?? collection, ref.$ref.key) : ref
|
|
9
|
+
export const uniqueIds = (ids: unknown[], ctx: z.RefinementCtx) => {
|
|
10
|
+
if (new Set(ids.map(String)).size !== ids.length)
|
|
11
|
+
ctx.addIssue({ code: 'custom', message: 'Each entry may be referenced once.', params: { code: 'duplicate' } })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const RelationOptions = z
|
|
15
|
+
.strictObject({
|
|
16
|
+
to: z.string().min(1),
|
|
17
|
+
cardinality: z.enum(['many-to-one', 'one-to-one', 'many-to-many', 'one-to-many']),
|
|
18
|
+
inverseOf: z.string().min(1).optional(),
|
|
19
|
+
depth: z.int().min(0).max(3).default(1),
|
|
20
|
+
})
|
|
21
|
+
.refine((o) => o.cardinality !== 'one-to-many' || o.inverseOf, { path: ['inverseOf'], message: 'one-to-many is always the derived side: name the many-to-one field on the target.' })
|
|
22
|
+
type O = z.infer<typeof RelationOptions>
|
|
23
|
+
/** The derived side stores nothing; its value is read from the target collection. */
|
|
24
|
+
export const isDerived = (o: O) => o.cardinality === 'one-to-many' || o.inverseOf !== undefined
|
|
25
|
+
const isMany = (o: O) => o.cardinality === 'many-to-many' || o.cardinality === 'one-to-many'
|
|
26
|
+
|
|
27
|
+
const stored = (o: O) => (isDerived(o) ? z.null() : isMany(o) ? z.array(Id).superRefine(uniqueIds) : Id.nullable())
|
|
28
|
+
type Stored = Id | Id[] | null
|
|
29
|
+
type SeedValue = z.infer<typeof EntryRef> | z.infer<typeof EntryRef>[] | null
|
|
30
|
+
const seed = (o: O): ZodType<SeedValue> => (isDerived(o) ? z.null() : isMany(o) ? z.array(EntryRef) : EntryRef.nullable())
|
|
31
|
+
const read = (o: O) => (isMany(o) ? z.array(EntryReadSchema) : EntryReadSchema.nullable())
|
|
32
|
+
const selfId = (at: string) => path.parse(at).id
|
|
33
|
+
|
|
34
|
+
const expand = (v: Stored, o: O, ctx: ExpandCtx): EntryRead | EntryRead[] | null => {
|
|
35
|
+
if (isDerived(o)) {
|
|
36
|
+
const inverse = ctx.inverse(o.to, o.inverseOf!, selfId(ctx.at))
|
|
37
|
+
return isMany(o) ? inverse : (inverse[0] ?? null)
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(v)) return v.map((id) => ctx.entry(o.to, id, o.depth))
|
|
40
|
+
return v === null ? null : ctx.entry(o.to, v, o.depth)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const relation = defineElement({
|
|
44
|
+
id: 'u90/relation',
|
|
45
|
+
version: 1,
|
|
46
|
+
label: 'Relation',
|
|
47
|
+
placement: 'collection',
|
|
48
|
+
options: RelationOptions,
|
|
49
|
+
seed,
|
|
50
|
+
stored,
|
|
51
|
+
read,
|
|
52
|
+
complete: (v, o, field) => (isDerived(o) ? [] : requiredIssue(Array.isArray(v) ? v.length > 0 : v != null, field)),
|
|
53
|
+
refs: (v, o, at): Ref[] => {
|
|
54
|
+
if (isDerived(o)) return [{ kind: 'inverse', collection: o.to, field: o.inverseOf!, id: selfId(at) }]
|
|
55
|
+
if (Array.isArray(v)) return v.map((id) => ({ kind: 'entry', collection: o.to, id, depth: o.depth }))
|
|
56
|
+
return v === null ? [] : [{ kind: 'entry', collection: o.to, id: v, depth: o.depth }]
|
|
57
|
+
},
|
|
58
|
+
toStored: async (s, o, ctx) => {
|
|
59
|
+
if (isDerived(o) || s === null) return null
|
|
60
|
+
return Array.isArray(s) ? s.map((r) => resolveEntryRef(r, o.to, ctx)) : resolveEntryRef(s, o.to, ctx)
|
|
61
|
+
},
|
|
62
|
+
expand,
|
|
63
|
+
expandSeed: (s, o, ctx) => expand(isDerived(o) || s === null ? null : Array.isArray(s) ? s.filter((r) => typeof r !== 'object') as Id[] : typeof s === 'object' ? null : s, o, ctx),
|
|
64
|
+
toText: () => '',
|
|
65
|
+
translatable: () => [],
|
|
66
|
+
translation: null,
|
|
67
|
+
empty: (o) => (isDerived(o) ? null : isMany(o) ? [] : null),
|
|
68
|
+
upgrade: {},
|
|
69
|
+
})
|
|
@@ -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 }
|
|
9
|
+
export const EntriesReadSchema = z.object({ entries: z.array(EntryReadSchema), total: z.number(), $path: 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
|
+
})
|