@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,24 @@
|
|
|
1
|
+
import NextLink from 'next/link'
|
|
2
|
+
import { Children, createElement, type ReactNode } from 'react'
|
|
3
|
+
import type { LinkRead } from '../elements'
|
|
4
|
+
import { draftAttrs } from './draft'
|
|
5
|
+
export const isInternal = (href: string) => href.startsWith('/') && !href.startsWith('//')
|
|
6
|
+
export function Link({ value, children, ...rest }: { value: LinkRead | null | undefined; children?: ReactNode } & Record<string, unknown>) {
|
|
7
|
+
if (!value) return null
|
|
8
|
+
const attrs = { ...rest, ...draftAttrs(value) }
|
|
9
|
+
// A label has its own editable surface: replacing the anchor's text would remove icons and
|
|
10
|
+
// other children. Only wrap the actual label, never arbitrary custom or icon-only content.
|
|
11
|
+
// Published markup is unchanged. Both markers use the link path, so selection stays on the link.
|
|
12
|
+
const content = children ?? value.label
|
|
13
|
+
const label = value.$path ? Children.map(content, child =>
|
|
14
|
+
typeof child === 'string' && child === value.label
|
|
15
|
+
? createElement('span', draftAttrs(value, 'link-label'), child)
|
|
16
|
+
: child,
|
|
17
|
+
) : content
|
|
18
|
+
if (!value.href) return createElement('span', attrs, label)
|
|
19
|
+
// A new-tab link gets target and rel; a caller's own target or rel wins over them (§5: the
|
|
20
|
+
// element's props pass through). The href is always the value's.
|
|
21
|
+
const tab: Record<string, unknown> = value.newTab ? { target: '_blank', rel: 'noopener' } : {}
|
|
22
|
+
for (const key of ['target', 'rel'] as const) if (rest[key] !== undefined) tab[key] = rest[key]
|
|
23
|
+
return isInternal(value.href) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createElement, type ElementType } from 'react'
|
|
2
|
+
import type { NumberRead } from '../elements'
|
|
3
|
+
import { draftAttrs } from './draft'
|
|
4
|
+
export function NumberText<T extends ElementType = 'span'>({ value, as, locale, format, ...rest }: { value: NumberRead | null | undefined; as?: T; locale?: string; format?: Intl.NumberFormatOptions } & Record<string, unknown>) {
|
|
5
|
+
if (!value) return null
|
|
6
|
+
const text = new Intl.NumberFormat(locale, format).format(value.value)
|
|
7
|
+
const attrs = draftAttrs(value)
|
|
8
|
+
return as ? createElement(as, { ...rest, ...attrs }, text) : value.$path ? createElement('span', attrs, text) : text
|
|
9
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createElement, Fragment, type ElementType, type ReactNode } from 'react'
|
|
2
|
+
import type { RichTextRead } from '../elements'
|
|
3
|
+
import { draftAttrs } from './draft'
|
|
4
|
+
type Node = { type: string; text?: string; format?: number; tag?: string; children?: Node[]; fields?: { href?: string | null; newTab?: boolean; to?: unknown }; listType?: string; key?: string }
|
|
5
|
+
const MARKS: Array<[number, string]> = [[1, 'strong'], [2, 'em'], [8, 'u'], [4, 's'], [16, 'code']]
|
|
6
|
+
const inline = (n: Node, key: number, draft: boolean): ReactNode => {
|
|
7
|
+
if (n.type === 'linebreak') return createElement('br', { key })
|
|
8
|
+
if (n.type === 'link') {
|
|
9
|
+
const anchor = Boolean(n.fields?.href)
|
|
10
|
+
// In draft, a link carries the field's own `{ to, newTab }` — v1's `LinkTo` — so
|
|
11
|
+
// `lexicalDom.ts` reads an untouched link back exactly rather than reclassifying it from
|
|
12
|
+
// `href` alone. The dead-link `<span>` (a deleted page, an unrouted entry) carries it too:
|
|
13
|
+
// without it the reader sees plain text, and the next keystroke in the field drops the link.
|
|
14
|
+
const linkAttrs = draft ? { 'data-u90-link': JSON.stringify({ to: n.fields?.to, newTab: Boolean(n.fields?.newTab) }) } : {}
|
|
15
|
+
return createElement(
|
|
16
|
+
anchor ? 'a' : 'span',
|
|
17
|
+
{ key, ...linkAttrs, ...(anchor ? { href: n.fields!.href, ...(n.fields!.newTab ? { target: '_blank', rel: 'noopener' } : {}) } : {}) },
|
|
18
|
+
...(n.children ?? []).map((c, i) => inline(c, i, draft)),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
// In draft a Single is a chip, inside its marks, so `lexicalDom.ts` reads the marks as the token's format (R38).
|
|
22
|
+
let out: ReactNode = draft && n.type === 'single' && typeof n.key === 'string'
|
|
23
|
+
? createElement('span', { 'data-single-key': n.key, contentEditable: false, suppressContentEditableWarning: true }, n.text ?? '')
|
|
24
|
+
: n.text ?? ''
|
|
25
|
+
for (const [bit, tag] of MARKS) if ((n.format ?? 0) & bit) out = createElement(tag, null, out)
|
|
26
|
+
return createElement(Fragment, { key }, out)
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A stored `tag` is data, not markup: only a real heading or list tag becomes an element. Anything
|
|
30
|
+
* else would throw at render (`img` is void, `h1 onclick=x` is no tag at all — a 500) or, as
|
|
31
|
+
* `plaintext`, swallow the rest of the document. A heading falls back to `h2`, a list to its
|
|
32
|
+
* `listType`.
|
|
33
|
+
*/
|
|
34
|
+
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
|
35
|
+
const LISTS = new Set(['ul', 'ol'])
|
|
36
|
+
const blockOf = (n: Node, key: number, draft: boolean): ReactNode => {
|
|
37
|
+
const kids = (n.children ?? []).map((c, i) => inline(c, i, draft))
|
|
38
|
+
if (n.type === 'heading') return createElement(n.tag && HEADINGS.has(n.tag) ? n.tag : 'h2', { key }, ...kids)
|
|
39
|
+
if (n.type === 'quote') return createElement('blockquote', { key }, ...kids)
|
|
40
|
+
if (n.type === 'list') return createElement(n.tag && LISTS.has(n.tag) ? n.tag : n.listType === 'number' ? 'ol' : 'ul', { key }, ...(n.children ?? []).map((li, i) => createElement('li', { key: i }, ...(li.children ?? []).map((c, j) => inline(c, j, draft)))))
|
|
41
|
+
return createElement('p', { key }, ...kids)
|
|
42
|
+
}
|
|
43
|
+
export const richTextNodes = (root: unknown, draft = false): ReactNode => ((root as { children?: Node[] })?.children ?? []).map((b, i) => blockOf(b, i, draft))
|
|
44
|
+
export function RichText<T extends ElementType = 'div'>({ value, as, ...rest }: { value: RichTextRead | null | undefined; as?: T } & Record<string, unknown>) {
|
|
45
|
+
if (!value) return null
|
|
46
|
+
return createElement(as ?? 'div', { ...rest, ...draftAttrs(value, 'rich') }, richTextNodes((value as { root: unknown }).root, Boolean((value as { $path?: string }).$path)))
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createElement, type ComponentProps, type ElementType, type ReactNode } from 'react'
|
|
2
|
+
import type { TextRead } from '../elements'
|
|
3
|
+
import { draftAttrs } from './draft'
|
|
4
|
+
type Props<T extends ElementType> = { value: TextRead | null | undefined; as?: T } & Omit<ComponentProps<T>, 'value' | 'as' | 'children'>
|
|
5
|
+
/** In draft, each resolved Single is a chip the caret cannot enter, which the bridge saves back as its `{key}` (R38). */
|
|
6
|
+
const chips = (value: TextRead): ReactNode =>
|
|
7
|
+
value.$path && value.segments
|
|
8
|
+
? value.segments.map((s, i) => (typeof s === 'string' ? s : createElement('span', { key: i, 'data-single-key': s.key, contentEditable: false, suppressContentEditableWarning: true }, s.text)))
|
|
9
|
+
: value.text
|
|
10
|
+
export function Text<T extends ElementType = 'span'>({ value, as, ...rest }: Props<T>) {
|
|
11
|
+
if (!value) return null
|
|
12
|
+
const attrs = draftAttrs(value, 'text')
|
|
13
|
+
if (!as) return value.$path ? createElement('span', attrs, chips(value)) : value.text
|
|
14
|
+
return createElement(as, { ...rest, ...attrs }, chips(value))
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Video.tsx (server-safe)
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import type { VideoRead } from '../elements'
|
|
4
|
+
import { draftAttrs } from './draft'
|
|
5
|
+
import { videoProps } from './videoProps'
|
|
6
|
+
import { VideoPlayback } from './VideoPlayback'
|
|
7
|
+
export function Video({ value, ...rest }: { value: VideoRead | null | undefined } & Record<string, unknown>) {
|
|
8
|
+
if (!value || !value.asset) return null
|
|
9
|
+
const bounded = value.startTime > 0 || value.endTime !== null
|
|
10
|
+
const el = createElement('video', { ...rest, ...draftAttrs(value), ...videoProps(value), ...(bounded ? { 'data-u90-start': value.startTime, 'data-u90-end': value.endTime ?? '' } : {}) })
|
|
11
|
+
return bounded ? createElement(VideoPlayback, null, el) : el
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
3
|
+
/** Seeks to data-u90-start on load and stops (or loops back) at data-u90-end. Renders only its child. */
|
|
4
|
+
export function VideoPlayback({ children }: { children: ReactNode }) {
|
|
5
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const v = ref.current?.querySelector('video'); if (!v) return
|
|
8
|
+
const start = Number(v.dataset.u90Start ?? 0), end = v.dataset.u90End ? Number(v.dataset.u90End) : null
|
|
9
|
+
const onMeta = () => { v.currentTime = start }
|
|
10
|
+
const onTime = () => { if (end !== null && v.currentTime >= end) { if (v.loop) { v.currentTime = start; void v.play().catch(() => {}) } else v.pause() } }
|
|
11
|
+
const onEnded = () => { if (end === null && v.loop) v.currentTime = start }
|
|
12
|
+
v.addEventListener('loadedmetadata', onMeta); v.addEventListener('timeupdate', onTime); v.addEventListener('ended', onEnded)
|
|
13
|
+
// The server-rendered <video> starts loading before this effect runs on hydration, so its
|
|
14
|
+
// metadata may already be in (HAVE_METADATA) — `loadedmetadata` has fired and will not again.
|
|
15
|
+
if (v.readyState >= 1) onMeta()
|
|
16
|
+
return () => { v.removeEventListener('loadedmetadata', onMeta); v.removeEventListener('timeupdate', onTime); v.removeEventListener('ended', onEnded) }
|
|
17
|
+
}, [])
|
|
18
|
+
return <span ref={ref} style={{ display: 'contents' }}>{children}</span>
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** `Record<string, unknown>` (rather than a bare `{ $path?: string }`) so TS's weak-type check does not reject a
|
|
2
|
+
* literal like `{ text: 'x' }` at call sites — every real read value has more fields than just `$path`. */
|
|
3
|
+
export type Marked = ({ $path?: string; $fallback?: boolean } & Record<string, unknown>) | null | undefined
|
|
4
|
+
export type DraftAttrs = { 'data-u90-field'?: string; 'data-u90-edit'?: 'text' | 'rich' | 'link-label'; 'data-u90-fallback'?: '' }
|
|
5
|
+
/** The one attribute (two for editable text) a draft render adds to the element it renders, and a third on a leaf that reads in a fallback language. Nothing in production. */
|
|
6
|
+
export const draftAttrs = (value: Marked, edit?: 'text' | 'rich' | 'link-label'): DraftAttrs =>
|
|
7
|
+
value && value.$path ? { 'data-u90-field': value.$path, ...(edit ? { 'data-u90-edit': edit } : {}), ...(value.$fallback ? { 'data-u90-fallback': '' as const } : {}) } : {}
|
|
8
|
+
export const editable = (value: Marked): DraftAttrs => draftAttrs(value, value && 'text' in value ? 'text' : undefined)
|
|
9
|
+
export const editableRich = (value: Marked): DraftAttrs => draftAttrs(value, 'rich')
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ImageRead, ImageReadVariant } from '../elements'
|
|
2
|
+
type Geometry = { focalPoint: { x: number; y: number } | null; crop: { x: number; y: number; width: number; height: number } | null }
|
|
3
|
+
const clamp = (v: number) => Math.min(100, Math.max(0, v))
|
|
4
|
+
/** Ported from legacy image.ts `imagePosition`: the focal point as CSS object-position, relative to the crop when there is one. */
|
|
5
|
+
export const objectPosition = (g: Geometry, fallback = '50% 50%'): string => {
|
|
6
|
+
const p = g.focalPoint
|
|
7
|
+
if (!p) return fallback
|
|
8
|
+
if (g.crop) return `${clamp(((p.x - g.crop.x) / g.crop.width) * 100)}% ${clamp(((p.y - g.crop.y) / g.crop.height) * 100)}%`
|
|
9
|
+
return `${clamp(p.x)}% ${clamp(p.y)}%`
|
|
10
|
+
}
|
|
11
|
+
export type ImageProps = { src: string; alt: string; width?: number; height?: number; fill?: boolean; sizes?: string; priority?: boolean; unoptimized?: boolean; style: { objectPosition: string } }
|
|
12
|
+
const source = (v: ImageReadVariant) => (v.status === 'ready' && v.rendition ? v.rendition : v.asset ? { url: v.asset.url, width: v.asset.width, height: v.asset.height } : null)
|
|
13
|
+
/** A draft grant path (a private item on a draft page): short-lived, so the site's image cache must never keep it. */
|
|
14
|
+
const GRANT = /^\/api\/u90\/media\/.+[/-]g\d{10}-[0-9a-f]{16}(?:\.webp)?$/
|
|
15
|
+
export const isGrantPath = (url: string): boolean => GRANT.test(url)
|
|
16
|
+
export function imageProps(value: ImageRead, opts: { sizes?: string; fill?: boolean; priority?: boolean }): ImageProps | null {
|
|
17
|
+
if (!value) return null
|
|
18
|
+
const s = source(value)
|
|
19
|
+
if (!s) return null
|
|
20
|
+
const base = { src: s.url, alt: value.decorative ? '' : value.alt, ...(opts.sizes ? { sizes: opts.sizes } : {}), ...(opts.priority ? { priority: true } : {}), ...(isGrantPath(s.url) ? { unoptimized: true } : {}), style: { objectPosition: objectPosition(value) } }
|
|
21
|
+
if (opts.fill) return { ...base, fill: true }
|
|
22
|
+
if (s.width && s.height) return { ...base, width: s.width, height: s.height }
|
|
23
|
+
throw new Error(`u90 <Image>: "${value.alt || s.url}" has no intrinsic size; pass fill inside a sized, positioned container.`)
|
|
24
|
+
}
|
|
25
|
+
export const pictureSources = (value: NonNullable<ImageRead>, declared: Record<string, { media: string }> = {}) =>
|
|
26
|
+
Object.entries(value.variants).flatMap(([name, v]) => { const s = source(v); const media = declared[name]?.media; return s && media ? [{ name, media, srcSet: s.url, width: s.width, height: s.height }] : [] })
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Text } from './Text'
|
|
2
|
+
export { RichText, richTextNodes } from './RichText'
|
|
3
|
+
export { Link, isInternal } from './Link'
|
|
4
|
+
export { File } from './File'
|
|
5
|
+
export { DateText } from './DateText'
|
|
6
|
+
export { NumberText } from './NumberText'
|
|
7
|
+
export { Image } from './Image'
|
|
8
|
+
export { imageProps, isGrantPath, objectPosition, pictureSources, type ImageProps } from './imageProps'
|
|
9
|
+
export { Video } from './Video'
|
|
10
|
+
export { videoProps, type VideoHTMLProps } from './videoProps'
|
|
11
|
+
export { draftAttrs, editable, editableRich, type DraftAttrs } from './draft'
|
|
12
|
+
export { LanguageSwitcher, useLanguage, rememberLanguage } from '../switcher/LanguageSwitcher'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// videoProps.ts — port of legacy media.ts videoProps over the read shape
|
|
2
|
+
import type { VideoRead } from '../elements'
|
|
3
|
+
export type VideoHTMLProps = { src?: string; poster?: string; autoPlay: boolean; muted: boolean; loop: boolean; controls: boolean; playsInline: boolean; preload: 'none' | 'metadata' | 'auto' }
|
|
4
|
+
export function videoProps(v: NonNullable<VideoRead>): VideoHTMLProps {
|
|
5
|
+
return { src: v.asset?.url, poster: v.poster?.status === 'ready' ? v.poster.rendition?.url : undefined, autoPlay: v.autoplay, muted: v.muted, loop: v.loop, controls: v.controls, playsInline: v.playsInline, preload: v.preload }
|
|
6
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineContainer, isLeaf, type GroupDecl, type LeafIssue } from '../define'
|
|
2
|
+
import { orderedEntries } from '../order'
|
|
3
|
+
|
|
4
|
+
export const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
5
|
+
|
|
6
|
+
export const group = defineContainer<GroupDecl>({
|
|
7
|
+
id: 'u90/group',
|
|
8
|
+
children: (decl, value) => {
|
|
9
|
+
const v = record(value) ? value : {}
|
|
10
|
+
return orderedEntries(decl.fields, decl.order).map(([name, field]) => {
|
|
11
|
+
const renamed = isLeaf(field) ? field.renamedFrom : undefined
|
|
12
|
+
const current = v[name] !== undefined ? v[name] : renamed !== undefined ? v[renamed] : undefined
|
|
13
|
+
return {
|
|
14
|
+
key: name,
|
|
15
|
+
decl: field,
|
|
16
|
+
value: current,
|
|
17
|
+
set: (next: unknown) => {
|
|
18
|
+
v[name] = next
|
|
19
|
+
if (renamed !== undefined && renamed in v) delete v[renamed]
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
},
|
|
24
|
+
enter: (decl, value) => {
|
|
25
|
+
if (value === undefined || value === null) return []
|
|
26
|
+
if (!record(value)) return [{ path: [], code: 'invalid_type', message: 'Expected an object.' }]
|
|
27
|
+
const known = new Set(['_id', '_block', ...Object.keys(decl.fields), ...Object.values(decl.fields).flatMap((f) => (isLeaf(f) && f.renamedFrom ? [f.renamedFrom] : []))])
|
|
28
|
+
const issues: LeafIssue[] = []
|
|
29
|
+
for (const key of Object.keys(value))
|
|
30
|
+
if (!known.has(key)) issues.push({ path: [key], code: 'unknown-field', message: `No field "${key}" is declared here.`, severity: 'warning' })
|
|
31
|
+
return issues
|
|
32
|
+
},
|
|
33
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineContainer, isLeaf, type ListDecl, type LeafIssue } from '../define'
|
|
2
|
+
import { ID_RE } from '../path'
|
|
3
|
+
import { record } from './group'
|
|
4
|
+
|
|
5
|
+
export const list = defineContainer<ListDecl>({
|
|
6
|
+
id: 'u90/list',
|
|
7
|
+
children: (decl, value) => {
|
|
8
|
+
if (!Array.isArray(value)) return []
|
|
9
|
+
const leaf = isLeaf(decl.item)
|
|
10
|
+
return value.map((item, i) => ({
|
|
11
|
+
key: record(item) && typeof item._id === 'string' ? item._id : `#${i}`,
|
|
12
|
+
decl: decl.item,
|
|
13
|
+
value: leaf ? (record(item) ? item._value : undefined) : item,
|
|
14
|
+
set: (next: unknown) => {
|
|
15
|
+
if (!leaf) value[i] = next
|
|
16
|
+
else if (record(item)) item._value = next
|
|
17
|
+
},
|
|
18
|
+
}))
|
|
19
|
+
},
|
|
20
|
+
enter: (decl, value) => {
|
|
21
|
+
if (value === undefined || value === null) return []
|
|
22
|
+
if (!Array.isArray(value)) return [{ path: [], code: 'invalid_type', message: 'Expected a list.' }]
|
|
23
|
+
const issues: LeafIssue[] = []
|
|
24
|
+
if (decl.min !== undefined && value.length < decl.min) issues.push({ path: [], code: 'too_small', message: `At least ${decl.min} item${decl.min === 1 ? '' : 's'}.` })
|
|
25
|
+
if (decl.max !== undefined && value.length > decl.max) issues.push({ path: [], code: 'too_big', message: `At most ${decl.max} item${decl.max === 1 ? '' : 's'}.` })
|
|
26
|
+
value.forEach((item, i) => {
|
|
27
|
+
if (!record(item) || typeof item._id !== 'string' || !ID_RE.test(item._id)) issues.push({ path: [i], code: 'missing-id', message: 'Every item needs an _id.' })
|
|
28
|
+
})
|
|
29
|
+
return issues
|
|
30
|
+
},
|
|
31
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { SLOT, defineContainer, type GroupDecl, type LeafIssue, type ZoneDecl } from '../define'
|
|
2
|
+
import { ID_RE } from '../path'
|
|
3
|
+
import { record } from './group'
|
|
4
|
+
|
|
5
|
+
/** Every item becomes a group child — a known block's fields, or no fields for SLOT and unknown blocks — so order is kept and nothing under SLOT is visited. */
|
|
6
|
+
export const zone = defineContainer<ZoneDecl>({
|
|
7
|
+
id: 'u90/zone',
|
|
8
|
+
children: (decl, value, schema) => {
|
|
9
|
+
if (!Array.isArray(value)) return []
|
|
10
|
+
return value.map((item, i) => {
|
|
11
|
+
const block = record(item) && typeof item._block === 'string' && item._block !== SLOT && Object.hasOwn(schema.blocks, item._block) ? schema.blocks[item._block] : undefined
|
|
12
|
+
return {
|
|
13
|
+
key: record(item) && typeof item._id === 'string' ? item._id : `#${i}`,
|
|
14
|
+
decl: { container: 'u90/group', fields: block?.fields ?? {}, order: block?.order } as GroupDecl,
|
|
15
|
+
value: item,
|
|
16
|
+
set: (next: unknown) => { value[i] = next },
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
enter: (decl, value, schema) => {
|
|
21
|
+
if (value === undefined || value === null) return []
|
|
22
|
+
if (!Array.isArray(value)) return [{ path: [], code: 'invalid_type', message: 'Expected a list of blocks.' }]
|
|
23
|
+
const issues: LeafIssue[] = []
|
|
24
|
+
value.forEach((item, i) => {
|
|
25
|
+
const id = record(item) && typeof item._id === 'string' ? item._id : i
|
|
26
|
+
if (!record(item) || typeof item._id !== 'string' || !ID_RE.test(item._id)) issues.push({ path: [id], code: 'missing-id', message: 'Every block needs an _id.' })
|
|
27
|
+
if (!record(item) || typeof item._block !== 'string') return issues.push({ path: [id], code: 'missing-block', message: 'Every item needs a _block.' })
|
|
28
|
+
if (item._block === SLOT) return
|
|
29
|
+
if (!Object.hasOwn(schema.blocks, item._block)) return issues.push({ path: [id, '_block'], code: 'unknown-block', message: `No block named "${item._block}".`, severity: 'warning' })
|
|
30
|
+
if (!decl.allow.includes(item._block)) issues.push({ path: [id, '_block'], code: 'not-allowed', message: `"${item._block}" is not allowed here.` })
|
|
31
|
+
})
|
|
32
|
+
if (decl.fixed && decl.default) {
|
|
33
|
+
// A fixed zone fixes which blocks it holds, each once; editors may still reorder them (decision 6).
|
|
34
|
+
const sorted = (names: unknown[]) => names.map(String).sort()
|
|
35
|
+
const actual = value.filter(record).map((x) => x._block).filter((b) => b !== SLOT)
|
|
36
|
+
if (JSON.stringify(sorted(actual)) !== JSON.stringify(sorted(decl.default)))
|
|
37
|
+
issues.push({ path: [], code: 'fixed', message: `This zone holds exactly these blocks, in any order: ${decl.default.join(', ')}.` })
|
|
38
|
+
}
|
|
39
|
+
return issues
|
|
40
|
+
},
|
|
41
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { BlockDecl, CollectionDecl, FieldDecl, Issue, LeafDecl, Schema, SingleDecl, ZoneDecl } from './define'
|
|
3
|
+
import { isLeaf } from './define'
|
|
4
|
+
import { ELEMENT_IDS, type ElementId } from './ids'
|
|
5
|
+
import { zodIssues } from './leaf'
|
|
6
|
+
import { getElement } from './registry'
|
|
7
|
+
|
|
8
|
+
const NAME = /^[a-z][a-zA-Z0-9]*$/
|
|
9
|
+
/**
|
|
10
|
+
* zod reports a bad record key through its own `invalid_key` issue rather than surfacing a `params.code`
|
|
11
|
+
* set on the key schema (verified against zod 4.4.3), so field names are checked in a `superRefine` instead,
|
|
12
|
+
* adding a `custom` issue with `params.code: 'field-name'` per bad key.
|
|
13
|
+
*/
|
|
14
|
+
const nameRecord = <T extends z.ZodTypeAny>(v: T) =>
|
|
15
|
+
z.record(z.string(), v).superRefine((obj, ctx) => {
|
|
16
|
+
for (const key of Object.keys(obj))
|
|
17
|
+
if (!NAME.test(key)) ctx.addIssue({ code: 'custom', message: 'Field names are camelCase identifiers.', params: { code: 'field-name' }, path: [key] })
|
|
18
|
+
})
|
|
19
|
+
/** `order` sits beside `fields`, never inside it: it lists a permutation of `fields`' own keys, each once. */
|
|
20
|
+
const checkOrder = (fields: Record<string, unknown>, order: string[] | undefined, ctx: z.RefinementCtx) => {
|
|
21
|
+
if (!order) return
|
|
22
|
+
const seen = new Set<string>()
|
|
23
|
+
for (const name of order) {
|
|
24
|
+
if (!Object.hasOwn(fields, name)) ctx.addIssue({ code: 'custom', message: `"${name}" is not a field here.`, path: ['order'] })
|
|
25
|
+
else if (seen.has(name)) ctx.addIssue({ code: 'custom', message: `"${name}" is listed twice.`, path: ['order'] })
|
|
26
|
+
seen.add(name)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LeafDeclSchema = z.strictObject({
|
|
31
|
+
element: z.enum(ELEMENT_IDS),
|
|
32
|
+
label: z.string().optional(),
|
|
33
|
+
description: z.string().optional(),
|
|
34
|
+
required: z.boolean().optional(),
|
|
35
|
+
translatable: z.boolean().optional(),
|
|
36
|
+
default: z.unknown().optional(),
|
|
37
|
+
lock: z.array(z.string().min(1)).optional(),
|
|
38
|
+
renamedFrom: z.string().regex(NAME).optional(),
|
|
39
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
40
|
+
})
|
|
41
|
+
export const FieldDeclSchema: z.ZodType<FieldDecl> = z.lazy(() =>
|
|
42
|
+
z.union([
|
|
43
|
+
LeafDeclSchema,
|
|
44
|
+
z.strictObject({ container: z.literal('u90/group'), label: z.string().optional(), fields: nameRecord(FieldDeclSchema), order: z.array(z.string()).optional() })
|
|
45
|
+
.superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx)),
|
|
46
|
+
z.strictObject({ container: z.literal('u90/list'), label: z.string().optional(), item: FieldDeclSchema, min: z.int().nonnegative().optional(), max: z.int().positive().optional(), default: z.array(z.unknown()).optional() }),
|
|
47
|
+
z.strictObject({ container: z.literal('u90/zone'), label: z.string().optional(), allow: z.array(z.string().min(1)).min(1), default: z.array(z.string().min(1)).optional(), fixed: z.boolean().optional() }),
|
|
48
|
+
]),
|
|
49
|
+
) as z.ZodType<FieldDecl>
|
|
50
|
+
const ZoneDeclSchema = z.strictObject({ container: z.literal('u90/zone'), label: z.string().optional(), allow: z.array(z.string().min(1)).min(1), default: z.array(z.string().min(1)).optional(), fixed: z.boolean().optional() })
|
|
51
|
+
export const BlockDeclSchema = z.strictObject({ name: z.string().regex(NAME), label: z.string(), fields: nameRecord(FieldDeclSchema), entry: z.string().optional(), order: z.array(z.string()).optional() })
|
|
52
|
+
.superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx))
|
|
53
|
+
export const CollectionDeclSchema = z.strictObject({
|
|
54
|
+
name: z.string().regex(NAME), label: z.string(), fields: nameRecord(FieldDeclSchema), template: ZoneDeclSchema.optional(),
|
|
55
|
+
defaultSort: z.union([z.strictObject({ field: z.string(), dir: z.enum(['asc', 'desc']) }), z.literal('manual')]).optional(),
|
|
56
|
+
order: z.array(z.string()).optional(),
|
|
57
|
+
}).superRefine((obj, ctx) => checkOrder(obj.fields, obj.order, ctx))
|
|
58
|
+
export const SingleDeclSchema = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: FieldDeclSchema })
|
|
59
|
+
export const SchemaSchema = z.strictObject({ blocks: z.record(z.string(), BlockDeclSchema), collections: z.record(z.string(), CollectionDeclSchema), singles: z.record(z.string(), SingleDeclSchema) })
|
|
60
|
+
|
|
61
|
+
type Where = 'block' | 'collection' | 'single'
|
|
62
|
+
const issue = (path: string[], code: string, message: string): Issue => ({ path: path.join('.'), code, message, severity: 'error' })
|
|
63
|
+
|
|
64
|
+
/** Parses, then checks everything the zod shape cannot: placement, cross-references, options, defaults, and fills query targetFields. */
|
|
65
|
+
export function validateSchema(input: unknown): { ok: true; schema: Schema } | { ok: false; issues: Issue[] } {
|
|
66
|
+
const parsed = SchemaSchema.safeParse(input)
|
|
67
|
+
if (!parsed.success) return { ok: false, issues: zodIssues(parsed.error).map((i) => issue(i.path.map(String), i.code, i.message)) }
|
|
68
|
+
const schema = parsed.data as Schema
|
|
69
|
+
const issues: Issue[] = []
|
|
70
|
+
const targetFields = (collection: string) => Object.fromEntries(Object.entries(schema.collections[collection]?.fields ?? {}).filter(([, d]) => isLeaf(d)).map(([n, d]) => [n, (d as LeafDecl).element]))
|
|
71
|
+
|
|
72
|
+
const leaf = (d: LeafDecl, at: string[], where: Where) => {
|
|
73
|
+
const entry = getElement(d.element)
|
|
74
|
+
if ((entry.placement === 'collection' && where !== 'collection') || (entry.placement === 'content' && where !== 'block'))
|
|
75
|
+
issues.push(issue(at, 'placement', `${d.element} may only be declared in ${entry.placement === 'collection' ? 'a collection' : 'a block'}.`))
|
|
76
|
+
if (d.element === 'u90/query' && d.options?.to && schema.collections[String(d.options.to)]) d.options = { ...d.options, targetFields: targetFields(String(d.options.to)) }
|
|
77
|
+
const o = entry.options.safeParse(d.options ?? {})
|
|
78
|
+
if (!o.success) { zodIssues(o.error).forEach((i) => issues.push(issue([...at, 'options', ...i.path.map(String)], i.code, i.message))); return }
|
|
79
|
+
const opts = o.data as Record<string, unknown>
|
|
80
|
+
if ((d.element === 'u90/relation' || d.element === 'u90/selection' || d.element === 'u90/query') && !schema.collections[String(opts.to)])
|
|
81
|
+
issues.push(issue([...at, 'options', 'to'], 'unknown-collection', `No collection named "${opts.to}".`))
|
|
82
|
+
if (d.element === 'u90/relation' && opts.inverseOf) {
|
|
83
|
+
const target = schema.collections[String(opts.to)]?.fields[String(opts.inverseOf)]
|
|
84
|
+
if (!target || !isLeaf(target) || target.element !== 'u90/relation') issues.push(issue([...at, 'options', 'inverseOf'], 'unknown-inverse', `"${opts.to}" has no relation field "${opts.inverseOf}".`))
|
|
85
|
+
}
|
|
86
|
+
if (d.element === 'u90/single' && !schema.singles[String(opts.key)]) issues.push(issue([...at, 'options', 'key'], 'unknown-single', `No Single named "${opts.key}".`))
|
|
87
|
+
if (d.default !== undefined) {
|
|
88
|
+
const s = entry.seed(o.data).safeParse(d.default)
|
|
89
|
+
if (!s.success) zodIssues(s.error).forEach((i) => issues.push(issue([...at, 'default', ...i.path.map(String)], i.code, i.message)))
|
|
90
|
+
else d.default = s.data
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const zone = (d: ZoneDecl, at: string[]) => {
|
|
94
|
+
d.allow.forEach((b, i) => { if (!schema.blocks[b]) issues.push(issue([...at, 'allow', String(i)], 'unknown-block', `No block named "${b}".`)) })
|
|
95
|
+
d.default?.forEach((b, i) => { if (!d.allow.includes(b)) issues.push(issue([...at, 'default', String(i)], 'not-allowed', `"${b}" is not in allow.`)) })
|
|
96
|
+
if (d.fixed && !d.default) issues.push(issue(at, 'fixed-needs-default', 'A fixed zone must list its blocks in default.'))
|
|
97
|
+
}
|
|
98
|
+
const field = (d: FieldDecl, at: string[], where: Where) => {
|
|
99
|
+
if (isLeaf(d)) return leaf(d, at, where)
|
|
100
|
+
if (d.container === 'u90/group') return Object.entries(d.fields).forEach(([n, f]) => field(f, [...at, 'fields', n], where))
|
|
101
|
+
if (d.container === 'u90/list') {
|
|
102
|
+
if (!isLeaf(d.item) && d.item.container !== 'u90/group')
|
|
103
|
+
issues.push(issue(at, 'list-item-kind', 'A list item must be an element or a group; wrap a list or a zone in a group.'))
|
|
104
|
+
return field(d.item, [...at, 'item'], where)
|
|
105
|
+
}
|
|
106
|
+
zone(d, at)
|
|
107
|
+
}
|
|
108
|
+
for (const [n, b] of Object.entries(schema.blocks)) {
|
|
109
|
+
if (b.entry && !schema.collections[b.entry]) issues.push(issue(['blocks', n, 'entry'], 'unknown-collection', `No collection named "${b.entry}".`))
|
|
110
|
+
Object.entries(b.fields).forEach(([f, d]) => field(d, ['blocks', n, 'fields', f], 'block'))
|
|
111
|
+
}
|
|
112
|
+
for (const [n, c] of Object.entries(schema.collections)) {
|
|
113
|
+
Object.entries(c.fields).forEach(([f, d]) => field(d, ['collections', n, 'fields', f], 'collection'))
|
|
114
|
+
if (c.template) zone(c.template, ['collections', n, 'template'])
|
|
115
|
+
}
|
|
116
|
+
for (const [n, s] of Object.entries(schema.singles)) field(s.field, ['singles', n, 'field'], 'single')
|
|
117
|
+
return issues.length ? { ok: false, issues } : { ok: true, schema }
|
|
118
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z, type ZodType } from 'zod'
|
|
2
|
+
import type { ContainerId, ElementId } from './ids'
|
|
3
|
+
|
|
4
|
+
export type Mode = 'draft' | 'publish'
|
|
5
|
+
export type Severity = 'error' | 'warning'
|
|
6
|
+
export type Id = string | number
|
|
7
|
+
/** A media, page or entry id as stored: a positive integer or a non-empty string. */
|
|
8
|
+
export const Id: ZodType<Id> = z.union([z.int().positive(), z.string().trim().min(1)])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An absolute http(s) URL, or a root-relative path. The character after the leading `/` may be
|
|
12
|
+
* neither `/` nor `\`: a browser reads `//host` and `/\host` alike as another host. Text and Link
|
|
13
|
+
* share it.
|
|
14
|
+
*/
|
|
15
|
+
export const URL_RE = /^(https?:\/\/\S+|\/(?:[^\/\\\s]\S*)?)$/
|
|
16
|
+
/** A phone number: an optional leading `+`, a digit, then at least five more digits, spaces, parentheses or hyphens. */
|
|
17
|
+
export const TEL_RE = /^\+?[0-9][0-9 ()\-]{5,}$/
|
|
18
|
+
|
|
19
|
+
/** An issue relative to one leaf value; the walker prefixes the leaf's path. */
|
|
20
|
+
export type LeafIssue = { path: (string | number)[]; code: string; message: string; severity?: Severity }
|
|
21
|
+
/** An issue at a full path such as `page:4f2/blocks/b1/photo/alt`. */
|
|
22
|
+
export type Issue = { path: string; code: string; message: string; severity: Severity }
|
|
23
|
+
|
|
24
|
+
export type Ref =
|
|
25
|
+
| { kind: 'media'; id: Id }
|
|
26
|
+
| { kind: 'page'; id: Id }
|
|
27
|
+
| { kind: 'entry'; collection: string; id: Id; depth: number }
|
|
28
|
+
| { kind: 'single'; key: string }
|
|
29
|
+
| { kind: 'query'; collection: string; query: unknown }
|
|
30
|
+
| { kind: 'inverse'; collection: string; field: string; id: Id }
|
|
31
|
+
|
|
32
|
+
export type ImageCrop = { x: number; y: number; width: number; height: number }
|
|
33
|
+
export type Rendition = {
|
|
34
|
+
crop: ImageCrop | null
|
|
35
|
+
url: string
|
|
36
|
+
width: number | null
|
|
37
|
+
height: number | null
|
|
38
|
+
blurDataURL?: string
|
|
39
|
+
status: 'ready' | 'processing' | 'error'
|
|
40
|
+
}
|
|
41
|
+
export type MediaRecord = {
|
|
42
|
+
id: Id
|
|
43
|
+
url: string
|
|
44
|
+
mimeType: string
|
|
45
|
+
width: number | null
|
|
46
|
+
height: number | null
|
|
47
|
+
filename: string
|
|
48
|
+
blurDataURL?: string
|
|
49
|
+
/** Renditions the platform has produced for this asset, one per crop. Absent means none yet. */
|
|
50
|
+
renditions?: Rendition[]
|
|
51
|
+
}
|
|
52
|
+
/** A media record as a read value carries it: everything but the platform's renditions. Image, Video and File share it. */
|
|
53
|
+
export type MediaAsset = Omit<MediaRecord, 'renditions'>
|
|
54
|
+
export const MediaAssetSchema = z.object({ id: Id, url: z.string(), mimeType: z.string(), width: z.number().nullable(), height: z.number().nullable(), filename: z.string(), blurDataURL: z.string().optional() })
|
|
55
|
+
export type PageRecord = { id: Id; route: string; title: string }
|
|
56
|
+
/** A stored entry as the platform hands it to `expand`. */
|
|
57
|
+
export type EntryRecord = { id: Id; slug: string; route?: string; fields: Record<string, unknown>; blocks?: unknown[] }
|
|
58
|
+
export type EntryRead = {
|
|
59
|
+
id: Id
|
|
60
|
+
collection: string
|
|
61
|
+
status: 'ready' | 'missing'
|
|
62
|
+
slug?: string
|
|
63
|
+
route?: string
|
|
64
|
+
/** Present when expanded to depth ≥ 1. */
|
|
65
|
+
fields?: Record<string, unknown>
|
|
66
|
+
}
|
|
67
|
+
/** Read schema of an entry reached through a relation, selection or query. */
|
|
68
|
+
export const EntryReadSchema = z.object({ id: Id, collection: z.string(), status: z.enum(['ready', 'missing']), slug: z.string().optional(), route: z.string().optional(), fields: z.record(z.string(), z.unknown()).optional() })
|
|
69
|
+
|
|
70
|
+
/** Everything `expand` may ask for. Built by `ops/expand` from pre-fetched data; the leaf never fetches. */
|
|
71
|
+
export type ExpandCtx = {
|
|
72
|
+
at: string
|
|
73
|
+
draft: boolean
|
|
74
|
+
/** Draft only: the overlay keys (`overlayKey(at)`) of this document's leaves that read in another language than the one asked for. */
|
|
75
|
+
fallbacks?: ReadonlySet<string>
|
|
76
|
+
/** The language a translated frame reads in, read from a draft request's `DRAFT_LOCALE_PARAM`, which internal link hrefs keep as that parameter. */
|
|
77
|
+
draftLocale?: string
|
|
78
|
+
media(id: Id): MediaRecord | undefined
|
|
79
|
+
page(id: Id): PageRecord | undefined
|
|
80
|
+
/** The Single's read value, already expanded by the caller. */
|
|
81
|
+
single(key: string): unknown
|
|
82
|
+
entry(collection: string, id: Id, depth: number): EntryRead
|
|
83
|
+
query(collection: string, query: unknown): { entries: EntryRead[]; total: number }
|
|
84
|
+
inverse(collection: string, field: string, id: Id): EntryRead[]
|
|
85
|
+
}
|
|
86
|
+
export type SeedCtx = {
|
|
87
|
+
importAsset(source: string): Promise<Id>
|
|
88
|
+
resolvePage(key: string): Id
|
|
89
|
+
resolveEntry(collection: string, key: string): Id
|
|
90
|
+
newId(): string
|
|
91
|
+
}
|
|
92
|
+
/** `hasDefault` is true only when the value is absent and a declared default reads in its place. */
|
|
93
|
+
export type FieldMeta = { required: boolean; hasDefault: boolean }
|
|
94
|
+
|
|
95
|
+
export type LeafDecl = {
|
|
96
|
+
element: ElementId
|
|
97
|
+
/** When `false`, the field is never translated. */
|
|
98
|
+
translatable?: boolean
|
|
99
|
+
label?: string
|
|
100
|
+
description?: string
|
|
101
|
+
required?: boolean
|
|
102
|
+
/** Seed shape. */
|
|
103
|
+
default?: unknown
|
|
104
|
+
/** Sub-paths a client may not change, e.g. `['to']` on a link. */
|
|
105
|
+
lock?: string[]
|
|
106
|
+
renamedFrom?: string
|
|
107
|
+
options?: Record<string, unknown>
|
|
108
|
+
}
|
|
109
|
+
export type GroupDecl = { container: 'u90/group'; label?: string; fields: Record<string, FieldDecl>; order?: string[] }
|
|
110
|
+
export type ListDecl = { container: 'u90/list'; label?: string; item: FieldDecl; min?: number; max?: number; default?: unknown[] }
|
|
111
|
+
export type ZoneDecl = { container: 'u90/zone'; label?: string; allow: string[]; default?: string[]; fixed?: boolean }
|
|
112
|
+
export type FieldDecl = LeafDecl | GroupDecl | ListDecl | ZoneDecl
|
|
113
|
+
export const isLeaf = (d: FieldDecl): d is LeafDecl => 'element' in d
|
|
114
|
+
|
|
115
|
+
export type BlockDecl = { name: string; label: string; fields: Record<string, FieldDecl>; entry?: string; order?: string[] }
|
|
116
|
+
export type CollectionDecl = {
|
|
117
|
+
name: string
|
|
118
|
+
label: string
|
|
119
|
+
fields: Record<string, FieldDecl>
|
|
120
|
+
template?: ZoneDecl
|
|
121
|
+
defaultSort?: { field: string; dir: 'asc' | 'desc' } | 'manual'
|
|
122
|
+
order?: string[]
|
|
123
|
+
}
|
|
124
|
+
export type SingleDecl = { key: string; field: FieldDecl }
|
|
125
|
+
export type Schema = {
|
|
126
|
+
blocks: Record<string, BlockDecl>
|
|
127
|
+
collections: Record<string, CollectionDecl>
|
|
128
|
+
singles: Record<string, SingleDecl>
|
|
129
|
+
}
|
|
130
|
+
export type DocumentDecl =
|
|
131
|
+
| { kind: 'page'; blocks: ZoneDecl }
|
|
132
|
+
| { kind: 'layout'; blocks: ZoneDecl }
|
|
133
|
+
| { kind: 'entry'; collection: string }
|
|
134
|
+
| { kind: 'single'; key: string }
|
|
135
|
+
|
|
136
|
+
export const SLOT = 'SLOT'
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What a language overlay holds for this leaf, and how it lands on the stored value. Destinations,
|
|
140
|
+
* media and structure never travel in an overlay: a link's overlay is its label, an image's its alt
|
|
141
|
+
* text and caption. `apply` answers undefined when there is no base value to translate.
|
|
142
|
+
*/
|
|
143
|
+
export type TranslationCodec<O, V, T> = {
|
|
144
|
+
applies(o: O): boolean
|
|
145
|
+
value(o: O): ZodType<T>
|
|
146
|
+
pick(v: V, o: O): T | null
|
|
147
|
+
apply(base: V | undefined, t: T, o: O): V | undefined
|
|
148
|
+
/** Whether a stored value has a part an overlay could land on, such as a video's poster. Absent, every stored value has. */
|
|
149
|
+
present?(v: V, o: O): boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type ElementEntry<O = any, S = unknown, V = unknown, R = unknown> = {
|
|
153
|
+
id: ElementId
|
|
154
|
+
version: number
|
|
155
|
+
label: string
|
|
156
|
+
placement: 'any' | 'collection' | 'content'
|
|
157
|
+
options: ZodType<O>
|
|
158
|
+
seed(o: O): ZodType<S>
|
|
159
|
+
stored(o: O): ZodType<V>
|
|
160
|
+
/**
|
|
161
|
+
* The lenient shape a stored value must have to be read; defaults to `stored(o)`. An element whose
|
|
162
|
+
* options narrow `stored` into an enum, a union or an integer declares one without those rules, so
|
|
163
|
+
* content written under wider options keeps reading while `validate` reports it.
|
|
164
|
+
*/
|
|
165
|
+
shape?(o: O): ZodType
|
|
166
|
+
read(o: O): ZodType<R>
|
|
167
|
+
/** Publish-only completeness. `v` is undefined when the field is absent from storage. */
|
|
168
|
+
complete(v: V | undefined, o: O, field: FieldMeta): LeafIssue[]
|
|
169
|
+
refs(v: V, o: O, at: string): Ref[]
|
|
170
|
+
toStored(seed: S, o: O, ctx: SeedCtx): Promise<V>
|
|
171
|
+
expand(v: V, o: O, ctx: ExpandCtx): R
|
|
172
|
+
/** Read shape of a seed value, for defaults of fields absent from storage. */
|
|
173
|
+
expandSeed(seed: S, o: O, ctx: ExpandCtx): R
|
|
174
|
+
toText(v: V, o: O): string
|
|
175
|
+
translatable(v: V, o: O): { path: (string | number)[]; value: string }[]
|
|
176
|
+
/** What a language overlay holds for this element; null where nothing translates. */
|
|
177
|
+
translation: TranslationCodec<O, V, any> | null
|
|
178
|
+
empty(o: O): V
|
|
179
|
+
upgrade: Record<number, (old: unknown) => unknown>
|
|
180
|
+
}
|
|
181
|
+
export const defineElement = <O, S, V, R>(e: ElementEntry<O, S, V, R>) => e
|
|
182
|
+
|
|
183
|
+
export type Child = { key: string; decl: FieldDecl; value: unknown; set(v: unknown): void }
|
|
184
|
+
export type ContainerEntry<D extends FieldDecl = FieldDecl> = {
|
|
185
|
+
id: ContainerId
|
|
186
|
+
children(decl: D, value: unknown, schema: Schema): Child[]
|
|
187
|
+
/** Structural checks on the container's own value; the walker prefixes the path. */
|
|
188
|
+
enter(decl: D, value: unknown, schema: Schema): LeafIssue[]
|
|
189
|
+
}
|
|
190
|
+
export const defineContainer = <D extends FieldDecl>(c: ContainerEntry<D>) => c
|
|
191
|
+
|
|
192
|
+
export const requiredIssue = (present: boolean, field: FieldMeta): LeafIssue[] =>
|
|
193
|
+
!present && field.required && !field.hasDefault
|
|
194
|
+
? [{ path: [], code: 'required', message: 'A value is required to publish.' }]
|
|
195
|
+
: []
|