@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 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.3 --version
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",
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.4.3"
63
+ "zod": "4.6.6"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "react": ">=19",
@@ -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'
@@ -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 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) }
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) => { const fields = n.fields as { to: z.infer<typeof LinkTo> }; Object.assign(fields, hrefOf(fields.to, ctx)) })
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) => 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
- if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block, ...metadata }
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
  }
@@ -2,7 +2,7 @@
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
 
4
4
  /**
5
- * `npx --yes @unitninety/u90@1.0.3 pair <code>` — the one command an operator hands over. The u90 tool
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.3 pair <code> --site https://example.com [--platform https://cms…]')
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]
@@ -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.3 pair\` writes both.`,
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
@@ -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
  }
@@ -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 =>
@@ -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. */
@@ -1 +1 @@
1
- export const SITE_KIT_VERSION = '1.6.3'
1
+ export const SITE_KIT_VERSION = '1.6.5'