@unitninety/site-kit 1.6.3 → 1.6.5
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 +69 -1
- package/package.json +2 -2
- package/src/components/Link.tsx +1 -1
- package/src/elements/declaration.ts +1 -1
- package/src/elements/define.ts +1 -1
- package/src/elements/leaves/link.ts +17 -2
- package/src/elements/leaves/rich-text.ts +32 -16
- package/src/elements/ops/expand.ts +4 -1
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/site/defineSite.ts +6 -3
- package/src/site/documents.ts +2 -2
- package/src/site/fields.ts +1 -1
- package/src/site/forms.ts +32 -0
- package/src/site/index.ts +2 -0
- package/src/site/types.ts +4 -1
- package/src/site/version.ts +1 -1
package/README.md
CHANGED
|
@@ -5,13 +5,19 @@ The library a UnitNinety site is built on. It reads the site's content from Unit
|
|
|
5
5
|
You do not usually add it by hand. The `u90` tool adds it when it converts a Next.js App Router site, at the one version that release of the tool works with:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx --yes @unitninety/u90@1.0.
|
|
8
|
+
npx --yes @unitninety/u90@1.0.5 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
UnitNinety imports packages made for that version only, so keep the version the tool names.
|
|
12
12
|
|
|
13
13
|
Documentation: [converting a site](https://unitninety.com/docs/convert), [importing it](https://unitninety.com/docs/import) and [the U90 package](https://unitninety.com/docs/package).
|
|
14
14
|
|
|
15
|
+
### Layout reordering in Canvas
|
|
16
|
+
|
|
17
|
+
Site Kit 1.6.5 gives every draft layout Slot its own preview address. Moving the Slot or placing a block before it updates immediately through the shared renderer, including nested layouts. The optimistic order stays visible while saving, and older responses cannot undo a newer move. Published output is unchanged.
|
|
18
|
+
|
|
19
|
+
Use Site Kit 1.6.5 with `@unitninety/u90@1.0.5` for new sites. Existing sites must update their Site Kit dependency and redeploy to receive the fix; updating the platform alone does not update a site's bundled renderer.
|
|
20
|
+
|
|
15
21
|
### Fixed field values
|
|
16
22
|
|
|
17
23
|
Use `lock: ['$']` on a leaf such as `number({ lock: ['$'] })` to retain a site-owned value without offering a content editor for it. Updates cannot change, clear, or remove that field, including when its previous value was absent. The stored value remains available to site reads and exports. Existing locks such as `lock: ['to']` on a link continue to protect only that subpath.
|
|
@@ -37,6 +43,24 @@ The form uses each field's label, default and text constraints. The list must co
|
|
|
37
43
|
|
|
38
44
|
Creation saves a draft with the normal schema defaults and approved template. Routed entries open Canvas for images and sections; collections without pages keep their content editor in the CMS. The matching platform/Site Kit implementation must be deployed together before using this new metadata in an uploaded package.
|
|
39
45
|
|
|
46
|
+
### Immediate authored-list previews
|
|
47
|
+
|
|
48
|
+
Client components inside a draft block can opt in with `useDraftContent`:
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
'use client'
|
|
52
|
+
import { useDraftContent } from '@unitninety/site-kit/bridge'
|
|
53
|
+
|
|
54
|
+
export function Header({ navigation }) {
|
|
55
|
+
const draft = useDraftContent(navigation, 'navigation')
|
|
56
|
+
return <nav>{draft.menuItems.map(item => <a key={item._id} href={item.href.href ?? '#'}>{item.label.text}</a>)}</nav>
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Call the hook on native reads before converting them for a presentation component. `renderBlocks` supplies the draft block path automatically; custom `DraftBlocks` renderers must supply each item's native `path`. Key rows by `_id`, not their editable label, URL or position. Existing server-only published content is unchanged.
|
|
61
|
+
|
|
62
|
+
Canvas prepares locally resolvable list defaults with a stable random item ID, previews the same list in both inspector and site, then saves through the normal validated queue. Nested lists, empty lists and duplicate responsive renderings work without DOM cloning. Later edits survive older refresh responses; rejected or ambiguous structural saves reconcile with an authoritative read. Draft IDs cannot collide with an existing item. Media/reference defaults requiring resolution still use the server path, and references continue using their existing entry previews. This opt-in requires the updated Site Kit; older bridges retain their ordinary DOM previews and refresh behavior.
|
|
63
|
+
|
|
40
64
|
### Immediate collection-list previews
|
|
41
65
|
|
|
42
66
|
Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
|
|
@@ -54,3 +78,47 @@ pending order/removal through slow refreshes; rejected saves reconcile to the se
|
|
|
54
78
|
Published output is an ordinary div and server-rendered cards. Newly added entries whose markup
|
|
55
79
|
is not already present appear when the save's server refresh arrives. Query results rendered
|
|
56
80
|
with a plain `.map()` still work, but wait for that refresh to change their order or membership.
|
|
81
|
+
|
|
82
|
+
### Shared blocks with page-only copies
|
|
83
|
+
|
|
84
|
+
A shared block uses one content group whose declaration matches a Single:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
const enquiryContent = group({ heading: text(), contact: link() })
|
|
88
|
+
const singles = defineSingles({ enquiry: enquiryContent })
|
|
89
|
+
const enquiry = block('enquiry', {
|
|
90
|
+
label: 'Enquiry',
|
|
91
|
+
fields: { content: enquiryContent },
|
|
92
|
+
shared: { single: 'enquiry', field: 'content' },
|
|
93
|
+
component: Enquiry,
|
|
94
|
+
})
|
|
95
|
+
// Include enquiry in a normal zone's allow/default lists.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A shared block may retain additional design fields only when every leaf is locked with `lock: ['$']`. Optional `shared.previewFields` lists the content keys this placement actually renders (1–30 unique existing keys); it filters both linked and page-specific trees without deleting stored data. Shared content uses the same accordion and selected-field editor as ordinary blocks.
|
|
99
|
+
|
|
100
|
+
The default `content: null` follows the Single. A content object is an independent copy owned by that block instance. Both render through the same component and retain the same block ID and controls. The Inspector labels the scope and offers shared editing or “Customise for this page”; customising copies the latest saved shared value. Templates seed the initial blocks, while an authored empty zone remains empty. Converting an existing hardcoded placement requires migrating its documents and renderer together.
|
|
101
|
+
|
|
102
|
+
### Inspector-only canvas editing
|
|
103
|
+
|
|
104
|
+
Pass `inlineEditing={false}` to `U90Bridge` in the root layout when clients should type only in the inspector. Canvas clicks still select and highlight fields; inspector text, rich-text and link-label previews remain immediate. The bridge keeps the `data-u90-edit` metadata for previews but does not add `contenteditable` or send canvas input edits. The option defaults to `true` for existing sites.
|
|
105
|
+
|
|
106
|
+
### Featured-entry fields in the block tree
|
|
107
|
+
|
|
108
|
+
Selections, relations and queries can declare the collection fields that their component displays:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
selection('projects', {
|
|
112
|
+
many: true,
|
|
113
|
+
depth: 2,
|
|
114
|
+
previewFields: ['title', 'heroMedia', 'industry', 'client', 'date', 'location'],
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`previewFields` is optional editor metadata: unique top-level field keys (1–20). It does not change stored references, query rules, ordering or read shapes. The same expandable rows work on pages, collection entries and layouts. A referenced entry expands inside its block; choosing a leaf opens its primitive editor above the tree. A referenced field can itself declare `previewFields` to expose a nested list. Cycles and deep chains fall back to the ordinary reference editor. Derived relations retain their existing read-only editor.
|
|
119
|
+
|
|
120
|
+
For example, KAW's Home declares `query('industries', { previewFields: ['name', 'description', 'capabilities'] })`. The Industries collection declares `relation('capabilities', { many: true, previewFields: ['name', 'heroMedia'] })`. Industry names, introductions, capability names and images still write to their original entry paths and update everywhere. Membership edits only change the owning reference. Queries preserve their rules and use pin/exclude/order controls; the plus control uses the existing tenant-scoped picker.
|
|
121
|
+
|
|
122
|
+
The site component must reveal the selected entry in its own tabs/carousels, including when its data arrives after selection. `useEditing()` exposes `selected` and `selectedVia`: the latter contains outer-to-inner reference field paths, disambiguating an entry reused in several parents. Mark a displayed list container with `data-u90-reference={reference.$path}` in draft mode to preserve this context when selecting its content on the canvas. Keep `data-u90-field` on each entry's own fields and a root marker on the revealed entry for row highlighting. A relation's target entry path is not its parent relation field path. These attributes do not introduce visitor-facing editing controls.
|
|
123
|
+
|
|
124
|
+
Deploy matching Site Kit/platform code before importing this metadata, and restart a running dev server after changing registry options so Payload validation hooks reload.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unitninety/site-kit",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.5",
|
|
4
4
|
"description": "The client-site half of the UnitNinety platform contract: schema authoring, content fetching, the editing bridge and the pairing CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"zod": "4.
|
|
63
|
+
"zod": "4.6.6"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"react": ">=19",
|
package/src/components/Link.tsx
CHANGED
|
@@ -20,5 +20,5 @@ export function Link({ value, children, ...rest }: { value: LinkRead | null | un
|
|
|
20
20
|
// element's props pass through). The href is always the value's.
|
|
21
21
|
const tab: Record<string, unknown> = value.newTab ? { target: '_blank', rel: 'noopener' } : {}
|
|
22
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)
|
|
23
|
+
return isInternal(value.href) && !(value.to && 'file' in value.to) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
24
24
|
}
|
|
@@ -84,7 +84,7 @@ export const CollectionDeclSchema = z.strictObject({
|
|
|
84
84
|
seen.add(name)
|
|
85
85
|
})
|
|
86
86
|
})
|
|
87
|
-
export const SingleDeclSchema = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: FieldDeclSchema })
|
|
87
|
+
export const SingleDeclSchema = z.strictObject({ key: z.string().regex(/^[a-z][a-zA-Z0-9_-]*$/), field: FieldDeclSchema, editor: z.enum(['cms', 'canvas']).optional() })
|
|
88
88
|
export const SchemaSchema = z.strictObject({ blocks: z.record(z.string(), BlockDeclSchema), collections: z.record(z.string(), CollectionDeclSchema), singles: z.record(z.string(), SingleDeclSchema) })
|
|
89
89
|
|
|
90
90
|
type Where = 'block' | 'collection' | 'single'
|
package/src/elements/define.ts
CHANGED
|
@@ -140,7 +140,7 @@ export type CollectionDecl = {
|
|
|
140
140
|
defaultSort?: { field: string; dir: 'asc' | 'desc' } | 'manual'
|
|
141
141
|
order?: string[]
|
|
142
142
|
}
|
|
143
|
-
export type SingleDecl = { key: string; field: FieldDecl }
|
|
143
|
+
export type SingleDecl = { key: string; field: FieldDecl; editor?: 'cms' | 'canvas' }
|
|
144
144
|
export type Schema = {
|
|
145
145
|
blocks: Record<string, BlockDecl>
|
|
146
146
|
collections: Record<string, CollectionDecl>
|
|
@@ -3,12 +3,13 @@ import { Id, TEL_RE, URL_RE, defineElement, requiredIssue, type ExpandCtx, type
|
|
|
3
3
|
import { isDocumentId } from '../ids'
|
|
4
4
|
|
|
5
5
|
const ANCHOR = z.string().regex(/^[A-Za-z][\w-]*$/)
|
|
6
|
-
const KINDS = ['page', 'entry', 'url', 'email', 'tel'] as const
|
|
6
|
+
const KINDS = ['page', 'entry', 'file', 'url', 'email', 'tel'] as const
|
|
7
7
|
export type LinkKind = (typeof KINDS)[number]
|
|
8
8
|
|
|
9
9
|
export const LinkTo = z.union([
|
|
10
10
|
z.strictObject({ page: Id, anchor: ANCHOR.optional() }),
|
|
11
11
|
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
12
|
+
z.strictObject({ file: Id }),
|
|
12
13
|
z.strictObject({ url: z.string().regex(URL_RE, 'Expected an absolute URL or a root-relative path.') }),
|
|
13
14
|
z.strictObject({ email: z.email() }),
|
|
14
15
|
z.strictObject({ tel: z.string().regex(TEL_RE, 'Expected a phone number.') }),
|
|
@@ -19,6 +20,7 @@ const LinkToSeed = z.union([
|
|
|
19
20
|
z.strictObject({ page: z.union([Id, z.string().min(1)]), anchor: ANCHOR.optional() }),
|
|
20
21
|
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), key: z.string().min(1) }) }),
|
|
21
22
|
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
23
|
+
z.strictObject({ file: z.union([Id, z.strictObject({ source: z.string().trim().min(1) })]) }),
|
|
22
24
|
z.strictObject({ url: z.string().regex(URL_RE) }),
|
|
23
25
|
z.strictObject({ email: z.email() }),
|
|
24
26
|
z.strictObject({ tel: z.string().regex(TEL_RE) }),
|
|
@@ -71,7 +73,7 @@ export const withDraftLocale = (href: string, locale: string): string => {
|
|
|
71
73
|
/** In draft with a `draftLocale`, an internal href keeps the language; every other href, and every href outside draft, is unchanged. */
|
|
72
74
|
export const hrefOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
73
75
|
const r = targetOf(to, ctx)
|
|
74
|
-
return ctx.draftLocale && r.href && internal(r.href) ? { ...r, href: withDraftLocale(r.href, ctx.draftLocale) } : r
|
|
76
|
+
return ctx.draftLocale && r.href && !(to && 'file' in to) && internal(r.href) ? { ...r, href: withDraftLocale(r.href, ctx.draftLocale) } : r
|
|
75
77
|
}
|
|
76
78
|
const targetOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
77
79
|
if (!to) return { href: null, status: 'ready' }
|
|
@@ -83,6 +85,13 @@ const targetOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; sta
|
|
|
83
85
|
const e = ctx.entry(to.entry.collection, to.entry.id, 0)
|
|
84
86
|
return e.status === 'ready' && e.route ? { href: e.route, status: 'ready' } : { href: null, status: 'missing' }
|
|
85
87
|
}
|
|
88
|
+
if ('file' in to) {
|
|
89
|
+
const media = ctx.media(to.file)
|
|
90
|
+
// Visibility is enforced by the platform's reference checks and media route. A resolver
|
|
91
|
+
// carrying additional visibility metadata must never turn a private file into a link.
|
|
92
|
+
const privateFile = media && 'visibility' in media && media.visibility === 'private'
|
|
93
|
+
return media?.url && !privateFile ? { href: media.url, status: 'ready' } : { href: null, status: 'missing' }
|
|
94
|
+
}
|
|
86
95
|
if ('url' in to) return { href: to.url, status: 'ready' }
|
|
87
96
|
if ('email' in to) return { href: `mailto:${to.email}`, status: 'ready' }
|
|
88
97
|
return { href: `tel:${to.tel.replace(/[^+\d]/g, '')}`, status: 'ready' }
|
|
@@ -100,6 +109,10 @@ const isUnresolvedSeedRef = (to: SeedTo | null): boolean =>
|
|
|
100
109
|
|
|
101
110
|
const expandSeed = (s: SeedValue, o: O, ctx: ExpandCtx): LinkRead => {
|
|
102
111
|
if (s === null) return null
|
|
112
|
+
if (s.to && 'file' in s.to && typeof s.to.file === 'object') {
|
|
113
|
+
const source = s.to.file.source
|
|
114
|
+
return { label: s.label, href: source, newTab: s.newTab, to: { file: source }, status: 'ready', ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
115
|
+
}
|
|
103
116
|
if (isUnresolvedSeedRef(s.to)) return { label: s.label, href: null, newTab: s.newTab, to: null, status: 'missing', ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
104
117
|
return expand({ label: s.label, to: s.to as LinkTo | null, newTab: s.newTab }, o, ctx)
|
|
105
118
|
}
|
|
@@ -124,6 +137,7 @@ export const link = defineElement({
|
|
|
124
137
|
if (!v?.to) return []
|
|
125
138
|
if ('page' in v.to) return [{ kind: 'page', id: v.to.page }]
|
|
126
139
|
if ('entry' in v.to) return [{ kind: 'entry', collection: v.to.entry.collection, id: v.to.entry.id, depth: 0 }]
|
|
140
|
+
if ('file' in v.to) return [{ kind: 'media', id: v.to.file }]
|
|
127
141
|
return []
|
|
128
142
|
},
|
|
129
143
|
toStored: async (s, _o, ctx: SeedCtx) => {
|
|
@@ -131,6 +145,7 @@ export const link = defineElement({
|
|
|
131
145
|
let to: LinkTo | null = null
|
|
132
146
|
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
147
|
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 } }
|
|
148
|
+
else if (s.to && 'file' in s.to) to = { file: typeof s.to.file === 'object' ? await ctx.importAsset(s.to.file.source) : s.to.file }
|
|
134
149
|
else to = s.to
|
|
135
150
|
return { label: s.label, to, newTab: s.newTab }
|
|
136
151
|
},
|
|
@@ -20,15 +20,16 @@ type O = z.infer<typeof RichTextOptions>
|
|
|
20
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
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
22
|
const LineBreak = z.object({ type: z.literal('linebreak'), version: z.int().default(1) })
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
23
|
+
const FileSeedTo = z.strictObject({ file: z.strictObject({ source: z.string().trim().min(1) }) })
|
|
24
|
+
const LinkNode = (seed = false) => z.object({ type: z.literal('link'), fields: z.object({ to: seed ? z.union([LinkTo, FileSeedTo]) : LinkTo, newTab: z.boolean().default(false) }), children: z.array(z.union([TextNode, LineBreak])), ...common })
|
|
25
|
+
const inlines = (o: O, seed = false) => z.array(o.marks.includes('link') ? z.union([TextNode, LineBreak, LinkNode(seed)]) : z.union([TextNode, LineBreak]))
|
|
26
|
+
const Paragraph = (o: O, seed = false) => z.object({ type: z.literal('paragraph'), children: inlines(o, seed), textFormat: z.int().default(0), ...common })
|
|
27
|
+
const Heading = (o: O, seed = false) => z.object({ type: z.literal('heading'), tag: z.enum(o.headings as [string, ...string[]]), children: inlines(o, seed), ...common })
|
|
28
|
+
const Quote = (o: O, seed = false) => z.object({ type: z.literal('quote'), children: inlines(o, seed), ...common })
|
|
29
|
+
const ListItem = (o: O, seed = false) => z.object({ type: z.literal('listitem'), children: inlines(o, seed), value: z.int().default(1), ...common })
|
|
30
|
+
const List = (o: O, seed = false) => 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, seed)), ...common })
|
|
31
|
+
const blockSchemas = (o: O, seed = false) => {
|
|
32
|
+
const by = { paragraph: Paragraph(o, seed), heading: Heading(o, seed), list: List(o, seed), quote: Quote(o, seed) }
|
|
32
33
|
const chosen = o.blocks.map((b) => by[b])
|
|
33
34
|
return chosen.length === 1 ? chosen[0] : z.union(chosen as z.ZodTypeAny[] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]])
|
|
34
35
|
}
|
|
@@ -44,9 +45,9 @@ const eachLink = (node: Node, f: (n: Node) => void) => {
|
|
|
44
45
|
node.children?.forEach((c) => eachLink(c, f))
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const stored = (o: O) =>
|
|
48
|
+
const stored = (o: O, seed = false) =>
|
|
48
49
|
z
|
|
49
|
-
.object({ root: z.object({ type: z.literal('root'), children: z.array(blockSchemas(o)), ...common }) })
|
|
50
|
+
.object({ root: z.object({ type: z.literal('root'), children: z.array(blockSchemas(o, seed)), ...common }) })
|
|
50
51
|
.superRefine((v, ctx) => {
|
|
51
52
|
const root = v.root as unknown as Node
|
|
52
53
|
if (o.inline && (root.children!.length !== 1 || root.children![0].type !== 'paragraph'))
|
|
@@ -70,10 +71,16 @@ export const richTextToPlain = (v: Stored): string =>
|
|
|
70
71
|
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
|
|
|
72
73
|
export type RichTextRead = ({ root: unknown; $path?: string }) | null
|
|
73
|
-
const expand = (v: Stored, o: O, ctx: ExpandCtx): RichTextRead => {
|
|
74
|
+
const expand = (v: Stored, o: O, ctx: ExpandCtx, seed = false): RichTextRead => {
|
|
74
75
|
if (v === null) return null
|
|
75
76
|
const root = structuredClone(v.root) as unknown as Node
|
|
76
|
-
eachLink(root, (n) => {
|
|
77
|
+
eachLink(root, (n) => {
|
|
78
|
+
const fields = n.fields as { to: z.infer<typeof LinkTo> | z.infer<typeof FileSeedTo> }
|
|
79
|
+
if (seed && 'file' in fields.to && typeof fields.to.file === 'object') {
|
|
80
|
+
const source = fields.to.file.source
|
|
81
|
+
Object.assign(fields, { to: { file: source }, href: source, status: 'ready' })
|
|
82
|
+
} else Object.assign(fields, hrefOf(fields.to as z.infer<typeof LinkTo>, ctx))
|
|
83
|
+
})
|
|
77
84
|
const split = (node: Node) => {
|
|
78
85
|
if (!node.children) return
|
|
79
86
|
node.children.forEach(split)
|
|
@@ -118,7 +125,7 @@ export const richText = defineElement({
|
|
|
118
125
|
label: 'Rich text',
|
|
119
126
|
placement: 'any',
|
|
120
127
|
options: RichTextOptions,
|
|
121
|
-
seed: stored,
|
|
128
|
+
seed: (o) => stored(o, true),
|
|
122
129
|
stored,
|
|
123
130
|
shape: () => SHAPE,
|
|
124
131
|
read: () => z.object({ root: z.unknown(), $path: z.string().optional() }).nullable(),
|
|
@@ -132,12 +139,21 @@ export const richText = defineElement({
|
|
|
132
139
|
const to = (n.fields as { to: z.infer<typeof LinkTo> }).to
|
|
133
140
|
if ('page' in to) refs.push({ kind: 'page', id: to.page })
|
|
134
141
|
else if ('entry' in to) refs.push({ kind: 'entry', collection: to.entry.collection, id: to.entry.id, depth: 0 })
|
|
142
|
+
else if ('file' in to) refs.push({ kind: 'media', id: to.file })
|
|
135
143
|
})
|
|
136
144
|
return refs
|
|
137
145
|
},
|
|
138
|
-
toStored: async (seed) =>
|
|
146
|
+
toStored: async (seed, _o, ctx) => {
|
|
147
|
+
if (seed === null) return null
|
|
148
|
+
const copy = structuredClone(seed)
|
|
149
|
+
await Promise.all(linkNodes(copy).map(async (node) => {
|
|
150
|
+
const fields = node.fields as { to: z.infer<typeof LinkTo> | z.infer<typeof FileSeedTo> }
|
|
151
|
+
if ('file' in fields.to && typeof fields.to.file === 'object') fields.to = { file: await ctx.importAsset(fields.to.file.source) }
|
|
152
|
+
}))
|
|
153
|
+
return copy
|
|
154
|
+
},
|
|
139
155
|
expand,
|
|
140
|
-
expandSeed: expand,
|
|
156
|
+
expandSeed: (s, o, ctx) => expand(s, o, ctx, true),
|
|
141
157
|
toText: richTextToPlain,
|
|
142
158
|
translatable: (v) => {
|
|
143
159
|
const out: { path: (string | number)[]; value: string }[] = []
|
|
@@ -62,7 +62,10 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
|
|
|
62
62
|
const item = items[i]
|
|
63
63
|
const block = record(item) ? String(item._block) : ''
|
|
64
64
|
const metadata = record(item) && typeof item._hidden === 'boolean' ? { _hidden: item._hidden } : {}
|
|
65
|
-
|
|
65
|
+
// The slot has no fields, but it is still a movable layout item. DraftBlocks needs
|
|
66
|
+
// its address both to move the page content and to place other blocks before it.
|
|
67
|
+
if (block === SLOT) return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}) }
|
|
68
|
+
if (!Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
|
|
66
69
|
return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
|
|
67
70
|
})
|
|
68
71
|
}
|
package/src/legacy/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `npx --yes @unitninety/u90@1.0.
|
|
5
|
+
* `npx --yes @unitninety/u90@1.0.5 pair <code>` — the one command an operator hands over. The u90 tool
|
|
6
6
|
* runs this file for `u90 pair`. The command is scoped and pinned because an unscoped name, run in a
|
|
7
7
|
* repository with no local bin, fetches whatever package npm holds under that name.
|
|
8
8
|
*
|
|
@@ -115,7 +115,7 @@ const run = async () => {
|
|
|
115
115
|
const args = process.argv.slice(2)
|
|
116
116
|
|
|
117
117
|
if (args[0] !== 'pair' || !args[1]) {
|
|
118
|
-
fail('Usage: npx --yes @unitninety/u90@1.0.
|
|
118
|
+
fail('Usage: npx --yes @unitninety/u90@1.0.5 pair <code> --site https://example.com [--platform https://cms…]')
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
const code = args[1]
|
package/src/legacy/content.ts
CHANGED
|
@@ -360,7 +360,7 @@ export const createContentClient = ({
|
|
|
360
360
|
: !platformUrl
|
|
361
361
|
? 'PLATFORM_URL is unset'
|
|
362
362
|
: 'PAYLOAD_API_KEY is unset'
|
|
363
|
-
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.
|
|
363
|
+
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.5 pair\` writes both.`,
|
|
364
364
|
)
|
|
365
365
|
}
|
|
366
366
|
return true
|
package/src/site/defineSite.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormsSchema, type FormDeclaration } from './forms'
|
|
1
2
|
import { seedIssues, validateSchema, type BlockDecl, type CollectionDecl, type FieldDecl, type Issue, type Schema, type SeedKnown, type ZoneDecl } from '../elements'
|
|
2
3
|
import { SLOT } from './documents'
|
|
3
4
|
import type { BlockDef, CollectionDef, LayoutDef, PageDef, PageTypeDef, Seo, Singles, Site, SiteManifest } from './types'
|
|
@@ -35,7 +36,8 @@ const misplacedZones = (decl: FieldDecl, at: string, direct: boolean, into: Issu
|
|
|
35
36
|
const PAGE_KEY = /^[a-z][a-zA-Z0-9_-]*$/
|
|
36
37
|
const strip = (fields: Record<string, unknown>) => Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, JSON.parse(JSON.stringify(v))])) as Record<string, FieldDecl>
|
|
37
38
|
|
|
38
|
-
export function defineSite(input: { singles: Singles; collections: Record<string, CollectionDef>; layouts: Record<string, LayoutDef>; pages: Record<string, PageDef>; pageTypes: Record<string, PageTypeDef>; seo?: Seo; knowledge?: KnowledgePolicyV1 }): Site {
|
|
39
|
+
export function defineSite(input: { singles: Singles; collections: Record<string, CollectionDef>; layouts: Record<string, LayoutDef>; pages: Record<string, PageDef>; pageTypes: Record<string, PageTypeDef>; seo?: Seo; knowledge?: KnowledgePolicyV1; forms?: FormDeclaration[] }): Site {
|
|
40
|
+
const forms = input.forms === undefined ? undefined : FormsSchema.parse(input.forms)
|
|
39
41
|
const defaults = Object.entries(input.layouts).filter(([, l]) => l.default)
|
|
40
42
|
if (defaults.length !== 1) throw new Error(`A site needs exactly one default layout; found ${defaults.length}.`)
|
|
41
43
|
const defaultLayout = defaults[0][0]
|
|
@@ -96,7 +98,7 @@ export function defineSite(input: { singles: Singles; collections: Record<string
|
|
|
96
98
|
const schema: Schema = {
|
|
97
99
|
blocks: Object.fromEntries([...blocks.values()].map((b) => [b.name, { name: b.name, label: b.label, fields: strip(b.fields), ...(b.shared ? { shared: b.shared } : {}), ...(b.entry ? { entry: b.entry.name } : {}), order: Object.keys(b.fields) } satisfies BlockDecl])),
|
|
98
100
|
collections: Object.fromEntries(Object.values(input.collections).map((c) => [c.name, { name: c.name, label: c.label, fields: strip(c.fields), ...(c.authoring ? { authoring: JSON.parse(JSON.stringify(c.authoring)) } : {}), ...(c.template ? { template: JSON.parse(JSON.stringify(c.template.zone)) } : {}), ...(c.defaultSort ? { defaultSort: c.defaultSort } : {}), order: Object.keys(c.fields) } satisfies CollectionDecl])),
|
|
99
|
-
singles: Object.fromEntries(Object.values(input.singles).map((s) => [s.key, { key: s.key, field: JSON.parse(JSON.stringify(s.decl)) }])),
|
|
101
|
+
singles: Object.fromEntries(Object.values(input.singles).map((s) => [s.key, { key: s.key, ...(s.editor ? { editor: s.editor } : {}), field: JSON.parse(JSON.stringify(s.decl)) }])),
|
|
100
102
|
}
|
|
101
103
|
// Finding 1: seed entries were checked only when seeded, at the platform or in the defaults store.
|
|
102
104
|
const known: SeedKnown = {
|
|
@@ -106,6 +108,7 @@ export function defineSite(input: { singles: Singles; collections: Record<string
|
|
|
106
108
|
const badSeeds = Object.values(input.collections).flatMap((c) => (c.entries ?? []).flatMap((e) => seedIssues(schema, { kind: 'entry', collection: c.name }, { fields: e.fields, blocks: e.blocks }, `collections.${c.name}.entries.${e.slug}`, known)))
|
|
107
109
|
if (badSeeds.length) throw new SiteDeclarationError(badSeeds)
|
|
108
110
|
return {
|
|
111
|
+
...(forms !== undefined ? { forms } : {}),
|
|
109
112
|
...(input.knowledge !== undefined ? { knowledge: input.knowledge } : {}),
|
|
110
113
|
singles: input.singles, collections: input.collections, blocks: Object.fromEntries(blocks), schema, seo: input.seo ?? {}, defaultLayout,
|
|
111
114
|
layouts: Object.fromEntries(Object.entries(input.layouts).map(([k, l]) => [k, { default: !!l.default, parent: l.parent, blocks: l.entries.map((e) => (e === SLOT ? 'SLOT' : e.name)) }])),
|
|
@@ -128,5 +131,5 @@ export function manifestFrom(site: Site): SiteManifest {
|
|
|
128
131
|
pageTypes: Object.keys(site.pageTypes),
|
|
129
132
|
layouts: Object.keys(site.layouts),
|
|
130
133
|
}
|
|
131
|
-
return { ...(site.knowledge !== undefined ? { knowledge: site.knowledge } : {}), schema: r.schema, layouts: site.layouts, pages: site.pages, pageTypes: site.pageTypes, entries, collections, routes, seo: site.seo, bridgeVersion: 4, siteKitVersion: SITE_KIT_VERSION, order }
|
|
134
|
+
return { ...(site.forms !== undefined ? { forms: FormsSchema.parse(site.forms) } : {}), ...(site.knowledge !== undefined ? { knowledge: site.knowledge } : {}), schema: r.schema, layouts: site.layouts, pages: site.pages, pageTypes: site.pageTypes, entries, collections, routes, seo: site.seo, bridgeVersion: 4, siteKitVersion: SITE_KIT_VERSION, order }
|
|
132
135
|
}
|
package/src/site/documents.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type { BlockDef, CollectionDef, Decl, EntrySeed, Fields, LayoutDef, PageD
|
|
|
4
4
|
export const SLOT = 'SLOT' as const
|
|
5
5
|
export const humanise = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
|
|
6
6
|
|
|
7
|
-
export const defineSingles = <M extends Record<string, Decl<unknown>>>(map: M): { [K in keyof M]: SingleDef<M[K] extends Decl<infer R> ? R : never> } =>
|
|
8
|
-
Object.fromEntries(Object.entries(map).map(([key, decl]) => [key, { key, decl }])) as never
|
|
7
|
+
export const defineSingles = <M extends Record<string, Decl<unknown>>>(map: M, authoring: Partial<Record<keyof M, { editor: 'cms' | 'canvas' }>> = {}): { [K in keyof M]: SingleDef<M[K] extends Decl<infer R> ? R : never> } =>
|
|
8
|
+
Object.fromEntries(Object.entries(map).map(([key, decl]) => [key, { key, decl, ...authoring[key] }])) as never
|
|
9
9
|
export const collection = <F extends Fields>(name: string, o: { label?: string; fields: F; route?: string; defaultSort?: CollectionDef['defaultSort']; entries?: EntrySeed[]; authoring?: CollectionDef['authoring'] }): CollectionDef<F> =>
|
|
10
10
|
({ kind: 'collection', name, label: o.label ?? humanise(name), fields: o.fields, route: o.route, defaultSort: o.defaultSort, ...(o.authoring ? { authoring: o.authoring } : {}), ...(o.entries ? { entries: o.entries } : {}) })
|
|
11
11
|
export const withTemplate = <C extends CollectionDef>(c: C, zone: Decl<unknown>, o: { layout?: string } = {}): C =>
|
package/src/site/fields.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const image = (o: Common & { default?: unknown; constraints?: unknown; va
|
|
|
19
19
|
leaf<ImageRead>('u90/image', o, { constraints: o.constraints, variants: o.variants })
|
|
20
20
|
export const video = (o: Common & { default?: unknown; poster?: unknown } = {}) => leaf<VideoRead>('u90/video', o, { poster: o.poster })
|
|
21
21
|
export const file = (o: Common & { default?: unknown; mimeTypes?: string[]; maxBytes?: number } = {}) => leaf<FileRead>('u90/file', o, { mimeTypes: o.mimeTypes, maxBytes: o.maxBytes })
|
|
22
|
-
export const link = (o: Common & { default?: unknown; allow?: Array<'page' | 'entry' | 'url' | 'email' | 'tel'> } = {}) => leaf<LinkRead>('u90/link', o, { allow: o.allow })
|
|
22
|
+
export const link = (o: Common & { default?: unknown; allow?: Array<'page' | 'entry' | 'file' | 'url' | 'email' | 'tel'> } = {}) => leaf<LinkRead>('u90/link', o, { allow: o.allow })
|
|
23
23
|
export const date = (o: Common & { default?: string; min?: string; max?: string } = {}) => leaf<DateRead>('u90/date', o, { min: o.min, max: o.max })
|
|
24
24
|
export const number = (o: Common & { default?: number; integer?: boolean; min?: number; max?: number; step?: number } = {}) => leaf<NumberRead>('u90/number', o, { integer: o.integer, min: o.min, max: o.max, step: o.step })
|
|
25
25
|
export const boolean = (o: Common & { default?: boolean } = {}) => leaf<BooleanRead>('u90/boolean', o, {})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const identifier = z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/).max(100)
|
|
4
|
+
const label = z.string().trim().min(1).max(200)
|
|
5
|
+
export const FormFieldSchema = z.strictObject({
|
|
6
|
+
name: identifier, label,
|
|
7
|
+
type: z.enum(['checkbox', 'date', 'email', 'number', 'select', 'tel', 'text', 'textarea']),
|
|
8
|
+
required: z.boolean().optional(), role: z.enum(['email', 'name', 'phone']).optional(),
|
|
9
|
+
options: z.array(label).max(200).optional(),
|
|
10
|
+
}).superRefine((field, ctx) => {
|
|
11
|
+
if (field.type === 'select' && !field.options?.length) ctx.addIssue({ code: 'custom', path: ['options'], message: 'Select fields need options.' })
|
|
12
|
+
if (field.options && new Set(field.options).size !== field.options.length) ctx.addIssue({ code: 'custom', path: ['options'], message: 'Options must be unique.' })
|
|
13
|
+
})
|
|
14
|
+
export const FormDeclarationSchema = z.strictObject({
|
|
15
|
+
slug: identifier, label, fields: z.array(FormFieldSchema).min(1).max(100),
|
|
16
|
+
recipients: z.array(z.email()).max(50).optional(),
|
|
17
|
+
}).superRefine((form, ctx) => {
|
|
18
|
+
const names = new Set<string>()
|
|
19
|
+
form.fields.forEach((field, i) => {
|
|
20
|
+
if (names.has(field.name)) ctx.addIssue({ code: 'custom', path: ['fields', i, 'name'], message: 'Field names must be unique.' })
|
|
21
|
+
names.add(field.name)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
export const FormsSchema = z.array(FormDeclarationSchema).max(100).superRefine((forms, ctx) => {
|
|
25
|
+
const slugs = new Set<string>()
|
|
26
|
+
forms.forEach((form, i) => {
|
|
27
|
+
if (slugs.has(form.slug)) ctx.addIssue({ code: 'custom', path: [i, 'slug'], message: 'Form slugs must be unique.' })
|
|
28
|
+
slugs.add(form.slug)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
export type FormDeclaration = z.infer<typeof FormDeclarationSchema>
|
|
32
|
+
export type FormFieldDeclaration = z.infer<typeof FormFieldSchema>
|
package/src/site/index.ts
CHANGED
|
@@ -15,3 +15,5 @@ export { sitemapFor } from '../seo/sitemap'
|
|
|
15
15
|
|
|
16
16
|
export { SeoSeedsSchema, DocumentSeoSeedSchema, SiteSeoSeedSchema, seoSeedIssues, seoSeedAssets, type SeoSeeds, type DocumentSeoSeed, type SiteSeoSeed } from '../seo/seeds'
|
|
17
17
|
export { CollectionViewSchema, CollectionViewsSchema, collectionViewFields, defaultCollectionView, reconcileCollectionView, validateCollectionView, collectionViewsIssues, type CollectionView, type CollectionViews, type CollectionViewField } from './formView'
|
|
18
|
+
|
|
19
|
+
export { FormsSchema, FormDeclarationSchema, FormFieldSchema, type FormDeclaration, type FormFieldDeclaration } from './forms'
|
package/src/site/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { KnowledgePolicyV1 } from '../features/contracts/knowledgePolicy'
|
|
2
|
+
import type { FormDeclaration } from './forms'
|
|
2
3
|
import type { ReactNode } from 'react'
|
|
3
4
|
import type { BlockDecl, CollectionDecl, FieldDecl, GroupDecl, LeafDecl, ListDecl, Schema, SingleDecl, ZoneDecl } from '../elements'
|
|
4
5
|
|
|
@@ -20,7 +21,7 @@ export type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
|
|
20
21
|
*/
|
|
21
22
|
export type ZoneNode = ReactNode
|
|
22
23
|
|
|
23
|
-
export type SingleDef<R = unknown> = { key: string; decl: Decl<R
|
|
24
|
+
export type SingleDef<R = unknown> = { key: string; decl: Decl<R>; editor?: 'cms' | 'canvas' }
|
|
24
25
|
export type Singles = Record<string, SingleDef>
|
|
25
26
|
/**
|
|
26
27
|
* An entry a collection starts with: its slug (also the key a seed's `{ $ref: { key } }` names it
|
|
@@ -45,6 +46,7 @@ export type Seo = { titleTemplate?: string; defaultImage?: string; organisation?
|
|
|
45
46
|
|
|
46
47
|
export type Site = {
|
|
47
48
|
knowledge?: KnowledgePolicyV1
|
|
49
|
+
forms?: FormDeclaration[]
|
|
48
50
|
singles: Singles; collections: Record<string, CollectionDef>
|
|
49
51
|
layouts: Record<string, { default: boolean; parent?: string; blocks: string[] }>
|
|
50
52
|
pages: Record<string, { route: string; layout: string; blocks: ZoneDecl }>
|
|
@@ -53,6 +55,7 @@ export type Site = {
|
|
|
53
55
|
}
|
|
54
56
|
export type SiteManifest = {
|
|
55
57
|
knowledge?: KnowledgePolicyV1
|
|
58
|
+
forms?: FormDeclaration[]
|
|
56
59
|
schema: Schema
|
|
57
60
|
layouts: Site['layouts']; pages: Site['pages']; pageTypes: Site['pageTypes']
|
|
58
61
|
/** Each collection's declared seed entries, by collection name; collections that declare none are absent. */
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.6.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.6.5'
|