@unitninety/site-kit 1.6.2 → 1.6.3

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.
Files changed (41) hide show
  1. package/README.md +26 -1
  2. package/package.json +1 -1
  3. package/src/bridge/DraftBlocks.tsx +14 -7
  4. package/src/bridge/U90Bridge.tsx +7 -4
  5. package/src/bridge/client.tsx +3 -2
  6. package/src/bridge/createBridge.ts +47 -12
  7. package/src/bridge/hooks.tsx +9 -8
  8. package/src/bridge/index.ts +6 -0
  9. package/src/bridge/nativeDraft.ts +177 -0
  10. package/src/bridge/protocol.ts +45 -7
  11. package/src/bridge/registry.ts +6 -1
  12. package/src/bridge/selection.ts +134 -0
  13. package/src/bridge/styles.ts +10 -1
  14. package/src/bridge/useDraftContent.tsx +151 -0
  15. package/src/elements/containers/group.ts +1 -1
  16. package/src/elements/containers/zone.ts +13 -2
  17. package/src/elements/declaration.ts +89 -2
  18. package/src/elements/define.ts +22 -3
  19. package/src/elements/jsonSchema.ts +4 -3
  20. package/src/elements/leaves/query.ts +6 -1
  21. package/src/elements/leaves/relation.ts +3 -0
  22. package/src/elements/leaves/selection.ts +5 -4
  23. package/src/elements/ops/expand.ts +5 -4
  24. package/src/elements/ops/seed.ts +25 -6
  25. package/src/elements/ops/validate.ts +31 -2
  26. package/src/legacy/cli.mjs +2 -2
  27. package/src/legacy/content.ts +1 -1
  28. package/src/seo/robots.ts +8 -8
  29. package/src/seo/seeds.ts +61 -0
  30. package/src/server/render.tsx +3 -3
  31. package/src/site/assemble.ts +1 -1
  32. package/src/site/defineSite.ts +2 -2
  33. package/src/site/documents.ts +4 -4
  34. package/src/site/fields.ts +5 -5
  35. package/src/site/formView.ts +175 -0
  36. package/src/site/index.ts +3 -0
  37. package/src/site/replies.ts +5 -4
  38. package/src/site/types.ts +3 -1
  39. package/src/site/version.ts +1 -1
  40. package/src/site/wire.ts +1 -1
  41. package/src/testing/fake.ts +4 -4
@@ -80,7 +80,7 @@ export type ExpandCtx = {
80
80
  /** The Single's read value, already expanded by the caller. */
81
81
  single(key: string): unknown
82
82
  entry(collection: string, id: Id, depth: number): EntryRead
83
- query(collection: string, query: unknown): { entries: EntryRead[]; total: number }
83
+ query(collection: string, query: unknown): { entries: EntryRead[]; total: number; matches?: Id[] }
84
84
  inverse(collection: string, field: string, id: Id): EntryRead[]
85
85
  }
86
86
  export type SeedCtx = {
@@ -101,7 +101,9 @@ export type LeafDecl = {
101
101
  required?: boolean
102
102
  /** Seed shape. */
103
103
  default?: unknown
104
- /** Sub-paths a client may not change, e.g. `['to']` on a link. */
104
+ /** Sub-paths a client may not change, e.g. `['to']` on a link. `['$']` fixes the whole
105
+ * value (including an absent value) on retained fields and omits its editor. Initial
106
+ * document seeding and new repeated items may initialise their values. */
105
107
  lock?: string[]
106
108
  renamedFrom?: string
107
109
  options?: Record<string, unknown>
@@ -112,12 +114,29 @@ export type ZoneDecl = { container: 'u90/zone'; label?: string; allow: string[];
112
114
  export type FieldDecl = LeafDecl | GroupDecl | ListDecl | ZoneDecl
113
115
  export const isLeaf = (d: FieldDecl): d is LeafDecl => 'element' in d
114
116
 
115
- export type BlockDecl = { name: string; label: string; fields: Record<string, FieldDecl>; entry?: string; order?: string[] }
117
+ export type BlockDecl = { name: string; label: string; fields: Record<string, FieldDecl>; entry?: string; order?: string[]; shared?: { single: string; field: string; previewFields?: string[] } }
118
+ /** Pinned content shown before or after a collection entry's editable page sections. */
119
+ export type CollectionAuthoringAnchor = { label: string; fields: string[] } | { label: string; single: string }
120
+ export type CollectionAuthoringSections = {
121
+ /** The collection template's sole fixed block, whose entry is this collection. */
122
+ block: string
123
+ /** A direct zone field on the wrapper; other fields must be whole-value-locked leaves. */
124
+ field: string
125
+ before?: CollectionAuthoringAnchor[]
126
+ after?: CollectionAuthoringAnchor[]
127
+ }
128
+ export type CollectionAuthoring = {
129
+ /** Concise creation form; omitted collections keep title-only creation. */
130
+ create?: { fields: string[] }
131
+ /** Exposes the wrapper's zone as page sections, with optional pinned content. */
132
+ sections?: CollectionAuthoringSections
133
+ }
116
134
  export type CollectionDecl = {
117
135
  name: string
118
136
  label: string
119
137
  fields: Record<string, FieldDecl>
120
138
  template?: ZoneDecl
139
+ authoring?: CollectionAuthoring
121
140
  defaultSort?: { field: string; dir: 'asc' | 'desc' } | 'manual'
122
141
  order?: string[]
123
142
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { isLeaf, type FieldDecl, type Schema } from './define'
2
+ import { isLeaf, SLOT, type FieldDecl, type Schema } from './define'
3
3
  import { orderedEntries } from './order'
4
4
  import { ID_RE } from './path'
5
5
  import { getElement } from './registry'
@@ -29,8 +29,9 @@ export function toJSONSchema(decl: FieldDecl, schema?: Schema): JsonSchema {
29
29
  }
30
30
  const blocks = decl.allow.map((name) => {
31
31
  const block = schema?.blocks[name]
32
- const fields = block ? (toJSONSchema({ container: 'u90/group', fields: block.fields, order: block.order }, schema).properties as object) : {}
33
- return { type: 'object', properties: { _id: id, _block: { const: name }, ...fields }, required: ['_id', '_block'], additionalProperties: false }
32
+ const fields = block ? (toJSONSchema({ container: 'u90/group', fields: block.fields, order: block.order }, schema).properties as Record<string, JsonSchema>) : {}
33
+ if (block?.shared) fields[block.shared.field] = { oneOf: [{ type: 'null', description: 'Use the shared content.' }, fields[block.shared.field]] }
34
+ return { type: 'object', properties: { _id: id, _block: { const: name }, ...(!decl.fixed && name !== SLOT ? { _hidden: { type: 'boolean' } } : {}), ...fields }, required: ['_id', '_block'], additionalProperties: false }
34
35
  })
35
36
  return { type: 'array', items: { oneOf: blocks } }
36
37
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { Id, defineElement, type ExpandCtx, type Ref } from '../define'
3
3
  import { ELEMENT_IDS, type ElementId } from '../ids'
4
+ import { ReferencePreviewFields } from './relation'
4
5
  import { EntriesReadSchema, type EntriesRead } from './selection'
5
6
 
6
7
  export const FILTERABLE: readonly ElementId[] = ['u90/choice', 'u90/boolean', 'u90/relation', 'u90/date', 'u90/number']
@@ -9,6 +10,7 @@ const OPS = ['eq', 'neq', 'in', 'not-in', 'is-set'] as const
9
10
 
10
11
  export const QueryOptions = z.strictObject({
11
12
  to: z.string().min(1),
13
+ previewFields: ReferencePreviewFields,
12
14
  /** field → element of the target collection; filled in by validateSchema from the CollectionDecl. */
13
15
  targetFields: z.record(z.string(), z.enum(ELEMENT_IDS)).optional(),
14
16
  })
@@ -77,7 +79,10 @@ const stored = (o: O) =>
77
79
  ctx.addIssue({ code: 'custom', path: [k], message: 'Each entry may be listed once.', params: { code: 'duplicate' } })
78
80
  }
79
81
  })
80
- const expand = (v: QueryStored, o: O, ctx: ExpandCtx): EntriesRead => ({ ...ctx.query(o.to, v), ...(ctx.draft ? { $path: ctx.at, $pins: (v.pin ?? []).map(String) } : {}) })
82
+ const expand = (v: QueryStored, o: O, ctx: ExpandCtx): EntriesRead => {
83
+ const { matches, ...read } = ctx.query(o.to, v)
84
+ return { ...read, ...(ctx.draft ? { $path: ctx.at, $pins: (v.pin ?? []).map(String), ...(matches ? { $query: { matches, where: v.where ?? [], sort: v.sort ?? null, limit: v.limit ?? null } } : {}) } : {}) }
85
+ }
81
86
 
82
87
  export const query = defineElement({
83
88
  id: 'u90/query',
@@ -11,9 +11,12 @@ export const uniqueIds = (ids: unknown[], ctx: z.RefinementCtx) => {
11
11
  ctx.addIssue({ code: 'custom', message: 'Each entry may be referenced once.', params: { code: 'duplicate' } })
12
12
  }
13
13
 
14
+ export const ReferencePreviewFields = z.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)).min(1).max(20).refine(fields => new Set(fields).size === fields.length, 'Preview fields must be unique').optional()
15
+
14
16
  export const RelationOptions = z
15
17
  .strictObject({
16
18
  to: z.string().min(1),
19
+ previewFields: ReferencePreviewFields,
17
20
  cardinality: z.enum(['many-to-one', 'one-to-one', 'many-to-many', 'one-to-many']),
18
21
  inverseOf: z.string().min(1).optional(),
19
22
  depth: z.int().min(0).max(3).default(1),
@@ -1,12 +1,13 @@
1
1
  import { z, type ZodType } from 'zod'
2
2
  import { EntryReadSchema, Id, defineElement, requiredIssue, type EntryRead, type ExpandCtx, type Ref } from '../define'
3
- import { EntryRef, resolveEntryRef, uniqueIds } from './relation'
3
+ import { EntryRef, ReferencePreviewFields, resolveEntryRef, uniqueIds } from './relation'
4
4
 
5
- export const SelectionOptions = z.strictObject({ to: z.string().min(1), many: z.boolean().default(false), max: z.int().positive().optional(), depth: z.int().min(0).max(3).default(1) })
5
+ export const SelectionOptions = z.strictObject({ to: z.string().min(1), many: z.boolean().default(false), max: z.int().positive().optional(), depth: z.int().min(0).max(3).default(1), previewFields: ReferencePreviewFields })
6
6
  type O = z.infer<typeof SelectionOptions>
7
7
  /** How a selection and a query read: the entries in order, how many there are, and `$path` in draft. */
8
- export type EntriesRead = { entries: EntryRead[]; total: number; $path?: string; $pins?: string[] }
9
- export const EntriesReadSchema = z.object({ entries: z.array(EntryReadSchema), total: z.number(), $path: z.string().optional(), $pins: z.array(z.string()).optional() })
8
+ export type QueryBaseline = { matches: (string | number)[]; where: unknown[]; sort: unknown; limit: number | null }
9
+ export type EntriesRead = { entries: EntryRead[]; total: number; $path?: string; $pins?: string[]; $query?: QueryBaseline }
10
+ export const EntriesReadSchema = z.object({ entries: z.array(EntryReadSchema), total: z.number(), $path: z.string().optional(), $pins: z.array(z.string()).optional(), $query: z.object({ matches: z.array(Id), where: z.array(z.unknown()), sort: z.unknown(), limit: z.number().nullable() }).optional() })
10
11
 
11
12
  const stored = (o: O) => (o.many ? (o.max ? z.array(Id).max(o.max) : z.array(Id)).superRefine(uniqueIds) : Id.nullable())
12
13
  type SeedValue = z.infer<typeof EntryRef> | z.infer<typeof EntryRef>[] | null
@@ -11,7 +11,7 @@ export type Resolved = {
11
11
  /** Singles already expanded to their read values by the caller. */
12
12
  singles?: Map<string, unknown>
13
13
  entries?: Map<string, EntryRecord>
14
- queries?: Map<string, { ids: Id[]; total: number }>
14
+ queries?: Map<string, { ids: Id[]; total: number; matches?: Id[] }>
15
15
  inverses?: Map<string, Id[]>
16
16
  }
17
17
  export const entryKey = (collection: string, id: Id) => `${collection}:${id}`
@@ -40,7 +40,7 @@ export function makeCtx(schema: Schema, r: Resolved, at: string, draft: boolean,
40
40
  },
41
41
  query: (collection, q) => {
42
42
  const hit = r.queries?.get(queryKey(collection, q))
43
- return hit ? { entries: hit.ids.map((id) => ctx.entry(collection, id, 1)), total: hit.total } : { entries: [], total: 0 }
43
+ return hit ? { entries: hit.ids.map((id) => ctx.entry(collection, id, 1)), total: hit.total, ...(draft && hit.matches ? { matches: hit.matches } : {}) } : { entries: [], total: 0 }
44
44
  },
45
45
  inverse: (collection, field, id) => (r.inverses?.get(inverseKey(collection, field, id)) ?? []).map((x) => ctx.entry(collection, x, 1)),
46
46
  }
@@ -61,8 +61,9 @@ export function expandField(schema: Schema, decl: FieldDecl, value: unknown, ctx
61
61
  if (decl.container === 'u90/list') return isLeaf(decl.item) ? inner : { _id: c.key, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
62
62
  const item = items[i]
63
63
  const block = record(item) ? String(item._block) : ''
64
- if (block === SLOT || !Object.hasOwn(schema.blocks, block)) return { _id: c.key, _block: block }
65
- return { _id: c.key, _block: block, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
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 }
66
+ return { _id: c.key, _block: block, ...metadata, ...(ctx.draft ? { $path: path.child(ctx.at, c.key) } : {}), ...(inner as object) }
66
67
  })
67
68
  }
68
69
 
@@ -4,6 +4,7 @@ import { zodIssues } from '../leaf'
4
4
  import { path } from '../path'
5
5
  import { CURRENT_CONTRACT, getElement } from '../registry'
6
6
  import { record } from '../containers/group'
7
+ import { blockFields } from '../containers/zone'
7
8
  import { orderedEntries } from '../order'
8
9
 
9
10
  export class SeedError extends Error {
@@ -19,6 +20,16 @@ const collector = () => {
19
20
  return { failures, fail }
20
21
  }
21
22
 
23
+ /** Shared admission rules: visibility belongs only to block instances in non-fixed zones. */
24
+ const visibilityIssues = (item: unknown, zone?: ZoneDecl): { code: string; message: string }[] => {
25
+ if (!record(item) || !Object.hasOwn(item, '_hidden')) return []
26
+ const issues: { code: string; message: string }[] = []
27
+ if (typeof item._hidden !== 'boolean') issues.push({ code: 'invalid_type', message: 'Block visibility must be a boolean.' })
28
+ if (!zone || item._block === SLOT) issues.push({ code: 'invalid-visibility', message: 'Only zone blocks can have visibility metadata.' })
29
+ else if (zone.fixed) issues.push({ code: 'fixed-visibility', message: 'Fixed blocks cannot have visibility metadata.' })
30
+ return issues
31
+ }
32
+
22
33
  /** One declared field's stored value, from an explicit seed value or its declared default. */
23
34
  async function seedField(schema: Schema, decl: FieldDecl, v: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<unknown> {
24
35
  if (isLeaf(decl)) {
@@ -32,6 +43,7 @@ async function seedField(schema: Schema, decl: FieldDecl, v: unknown, ctx: SeedC
32
43
  }
33
44
  if (decl.container === 'u90/group') {
34
45
  const src = record(v) ? v : {}
46
+ visibilityIssues(src).forEach(i => fail(path.child(here, '_hidden'), i.message))
35
47
  const out: Record<string, unknown> = {}
36
48
  for (const [name, f] of orderedEntries(decl.fields, decl.order)) out[name] = await seedField(schema, f, src[name], ctx, path.child(here, name), fail)
37
49
  return out
@@ -50,18 +62,20 @@ async function seedField(schema: Schema, decl: FieldDecl, v: unknown, ctx: SeedC
50
62
  }
51
63
  async function seedListItem(schema: Schema, decl: ListDecl, item: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<Record<string, unknown>> {
52
64
  const _id = ctx.newId()
65
+ if (isLeaf(decl.item)) visibilityIssues(item).forEach(i => fail(path.child(here, _id, '_hidden'), i.message))
53
66
  const inner = await seedField(schema, decl.item, item, ctx, path.child(here, _id), fail)
54
67
  return isLeaf(decl.item) ? { _id, _value: inner } : { _id, ...(inner as object) }
55
68
  }
56
69
  async function seedZoneItem(schema: Schema, decl: ZoneDecl, item: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<Record<string, unknown> | null> {
57
70
  const _id = ctx.newId()
58
71
  const block = record(item) && typeof item._block === 'string' ? item._block : ''
72
+ visibilityIssues(item, decl).forEach(i => fail(path.child(here, _id, '_hidden'), i.message))
59
73
  if (block === SLOT) return { _id, _block: SLOT }
60
74
  const declared = Object.hasOwn(schema.blocks, block) ? schema.blocks[block] : undefined
61
75
  if (!declared) { fail(path.child(here, _id), `No block named "${block}".`); return null }
62
76
  if (!decl.allow.includes(block)) fail(path.child(here, _id), `"${block}" is not allowed here.`)
63
- const { _block: _b, ...rest } = item as Record<string, unknown>
64
- return { _id, _block: block, ...((await seedField(schema, { container: 'u90/group', fields: declared.fields, order: declared.order }, rest, ctx, path.child(here, _id), fail)) as object) }
77
+ const { _block: _b, _hidden, ...rest } = item as Record<string, unknown>
78
+ return { _id, _block: block, ...(typeof _hidden === 'boolean' ? { _hidden } : {}), ...((await seedField(schema, blockFields(declared, rest), rest, ctx, path.child(here, _id), fail)) as object) }
65
79
  }
66
80
 
67
81
  /** Declared defaults, or an explicit seed value, to stored JSON. Every failing path is collected before throwing. */
@@ -152,21 +166,26 @@ export function seedIssues(schema: Schema, docDecl: DocumentDecl, value: unknown
152
166
  }
153
167
  if (decl.container === 'u90/group') {
154
168
  if (!record(v)) return add(here, 'invalid_type', 'Expected an object of fields.')
155
- for (const k of Object.keys(v)) if (!Object.hasOwn(decl.fields, k)) add(path.child(here, k), 'unknown-field', `No field named "${k}".`)
169
+ visibilityIssues(v).forEach(i => add(path.child(here, '_hidden'), i.code, i.message))
170
+ for (const k of Object.keys(v)) if (k !== '_hidden' && !Object.hasOwn(decl.fields, k)) add(path.child(here, k), 'unknown-field', `No field named "${k}".`)
156
171
  for (const [name, f] of orderedEntries(decl.fields, decl.order)) field(f, v[name], path.child(here, name))
157
172
  return
158
173
  }
159
174
  if (!Array.isArray(v)) return add(here, 'invalid_type', decl.container === 'u90/list' ? 'Expected a list.' : 'Expected a list of blocks.')
160
- if (decl.container === 'u90/list') return v.forEach((item, i) => field(decl.item, item, path.child(here, i)))
175
+ if (decl.container === 'u90/list') return v.forEach((item, i) => {
176
+ if (isLeaf(decl.item)) visibilityIssues(item).forEach(issue => add(path.child(here, i, '_hidden'), issue.code, issue.message))
177
+ field(decl.item, item, path.child(here, i))
178
+ })
161
179
  v.forEach((item, i) => {
162
180
  const itemAt = path.child(here, i)
163
181
  const block = record(item) && typeof item._block === 'string' ? item._block : ''
182
+ visibilityIssues(item, decl).forEach(issue => add(path.child(itemAt, '_hidden'), issue.code, issue.message))
164
183
  if (block === SLOT) return
165
184
  const declared = Object.hasOwn(schema.blocks, block) ? schema.blocks[block] : undefined
166
185
  if (!declared) return add(itemAt, 'unknown-block', `No block named "${block}".`)
167
186
  if (!decl.allow.includes(block)) add(itemAt, 'not-allowed', `"${block}" is not allowed here.`)
168
- const { _block: _b, ...rest } = item as Record<string, unknown>
169
- field({ container: 'u90/group', fields: declared.fields, order: declared.order }, rest, itemAt)
187
+ const { _block: _b, _hidden: _h, ...rest } = item as Record<string, unknown>
188
+ field(blockFields(declared, rest), rest, itemAt)
170
189
  })
171
190
  }
172
191
  if (docDecl.kind === 'entry') {
@@ -1,3 +1,4 @@
1
+ import { canonicalJson } from '../../site/wire'
1
2
  import { SLOT, type DocumentDecl, type Issue, type LeafIssue, type Mode, type Schema } from '../define'
2
3
  import { hasStoredShape, validateLeaf } from '../leaf'
3
4
  import { path } from '../path'
@@ -15,14 +16,39 @@ export function validate(schema: Schema, docDecl: DocumentDecl, doc: unknown, at
15
16
  const issues: Issue[] = []
16
17
  const push = (base: string, i: LeafIssue) => issues.push({ path: path.child(base, ...i.path), code: i.code, message: i.message, severity: i.severity ?? 'error' })
17
18
  const ids = new Map<string, string>()
19
+ const blockPaths = new Set<string>()
20
+ const hiddenPaths: string[] = []
18
21
  const layoutRoot = docDecl.kind === 'layout' ? path.child(at, 'blocks') : null
19
22
  const root = (p: string, code: string, message: string): Issue[] => [{ path: p, code, message, severity: 'error' }]
20
23
  if (!record(doc)) return root(at, 'invalid_type', 'A stored document is an object.')
21
24
  if (docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) return root(at, 'unknown-collection', `No collection named "${docDecl.collection}".`)
22
25
  if (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key)) return root(at, 'unknown-single', `No Single named "${docDecl.key}".`)
23
26
  if (docDecl.kind === 'entry' && doc.fields !== undefined && !record(doc.fields)) return root(path.child(at, 'fields'), 'invalid_type', 'An entry stores its fields as an object.')
27
+ // Walking the old declaration also records absent fields. A new repeated item's path is
28
+ // absent from this map, so its defaults can initialise without unlocking retained fields.
29
+ const wholeLockedBefore = new Map<string, unknown>()
30
+ if (opts.previous !== undefined) walkDocument(schema, docDecl, opts.previous, {
31
+ leaf: ({ decl, value, at: here }) => { if (decl.lock?.includes('$')) wholeLockedBefore.set(here, value) },
32
+ }, at)
24
33
  walkDocument(schema, docDecl, doc, {
25
34
  enter: ({ container, decl, value, at: here }) => {
35
+ if (container.id === 'u90/zone' && Array.isArray(value)) {
36
+ for (const item of value) if (record(item)) {
37
+ const itemPath = path.child(here, String(item._id))
38
+ blockPaths.add(itemPath)
39
+ if (Object.hasOwn(item, '_hidden')) {
40
+ if (typeof item._hidden !== 'boolean') push(itemPath, { path: ['_hidden'], code: 'invalid_type', message: 'Block visibility must be a boolean.' })
41
+ if ('fixed' in decl && decl.fixed) push(itemPath, { path: ['_hidden'], code: 'fixed-visibility', message: 'Fixed blocks cannot have visibility metadata.' })
42
+ if (item._block === SLOT) push(itemPath, { path: ['_hidden'], code: 'invalid-visibility', message: 'SLOT cannot have visibility metadata.' })
43
+ if (item._hidden === true) hiddenPaths.push(itemPath)
44
+ }
45
+ }
46
+ }
47
+ if (record(value) && Object.hasOwn(value, '_hidden') && !blockPaths.has(here))
48
+ push(here, { path: ['_hidden'], code: 'invalid-visibility', message: 'Only zone blocks can have visibility metadata.' })
49
+ if (container.id === 'u90/list' && Array.isArray(value))
50
+ for (const item of value) if (record(item) && Object.hasOwn(item, '_hidden'))
51
+ push(path.child(here, String(item._id)), { path: ['_hidden'], code: 'invalid-visibility', message: 'List items cannot have visibility metadata.' })
26
52
  container.enter(decl, value, schema).forEach((i) => push(here, i))
27
53
  if (container.id === 'u90/zone' && slots(value) !== (here === layoutRoot ? 1 : 0))
28
54
  push(here, { path: [], code: 'slot', message: here === layoutRoot ? 'A layout holds exactly one SLOT.' : 'Only a layout may hold a SLOT.' })
@@ -34,8 +60,11 @@ export function validate(schema: Schema, docDecl: DocumentDecl, doc: unknown, at
34
60
  }
35
61
  },
36
62
  leaf: ({ entry, decl, value, at: here }) => {
37
- validateLeaf(entry, decl, value, opts.mode).forEach((i) => push(here, i))
38
- if (decl.lock?.length && opts.previous !== undefined) {
63
+ validateLeaf(entry, decl, value, hiddenPaths.some(p => here.startsWith(`${p}/`)) ? 'draft' : opts.mode).forEach((i) => push(here, i))
64
+ if (decl.lock?.includes('$') && wholeLockedBefore.has(here)) {
65
+ if (canonicalJson(wholeLockedBefore.get(here)) !== canonicalJson(value))
66
+ push(here, { path: [], code: 'locked', message: 'This field is set by the site and cannot be changed here.' })
67
+ } else if (decl.lock?.length && !decl.lock.includes('$') && opts.previous !== undefined) {
39
68
  const pointer = path.toPointer(opts.previous, here)
40
69
  const before = pointer === null ? undefined : valueAtPointer(opts.previous, pointer)
41
70
  if (before !== undefined)
@@ -2,7 +2,7 @@
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
 
4
4
  /**
5
- * `npx --yes @unitninety/u90@1.0.1 pair <code>` — the one command an operator hands over. The u90 tool
5
+ * `npx --yes @unitninety/u90@1.0.3 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.1 pair <code> --site https://example.com [--platform https://cms…]')
118
+ fail('Usage: npx --yes @unitninety/u90@1.0.3 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.1 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.3 pair\` writes both.`,
364
364
  )
365
365
  }
366
366
  return true
package/src/seo/robots.ts CHANGED
@@ -3,12 +3,12 @@ import type { SiteReply } from '../site/wire'
3
3
  import { siteUrlFor } from './metadata'
4
4
 
5
5
  /**
6
- * `robots.txt`: every crawler is allowed, and the sitemap named, unless the platform's
7
- * `allowIndexing` is off, when everything is disallowed and no sitemap is named. A `/site` read that
8
- * failed (`site` null) allows crawling, so a platform hiccup never takes a site out of search. With
9
- * no origin there is no absolute sitemap URL to name.
6
+ * Public pages remain crawlable even with indexing off: crawlers must fetch them to see
7
+ * metadata's noindex. Only indexable sites advertise a sitemap. Private previews are
8
+ * protected separately by the preview access gate and its X-Robots-Tag headers.
9
+ * A failed site read retains normal discovery rather than changing search policy.
10
10
  */
11
- export const robotsFor = ({ origin, site }: { origin: string | undefined; site: SiteReply | null }): MetadataRoute.Robots =>
12
- site?.allowIndexing === false
13
- ? { rules: { userAgent: '*', disallow: '/' } }
14
- : { rules: { userAgent: '*', allow: '/' }, ...(origin ? { sitemap: siteUrlFor(origin, '/sitemap.xml') } : {}) }
11
+ export const robotsFor = ({ origin, site }: { origin: string | undefined; site: SiteReply | null }): MetadataRoute.Robots => ({
12
+ rules: { userAgent: '*', allow: '/' },
13
+ ...(site?.allowIndexing !== false && origin ? { sitemap: siteUrlFor(origin, '/sitemap.xml') } : {}),
14
+ })
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod'
2
+ import type { SiteManifest } from '../site/types'
3
+
4
+ /** Portable SEO contains authored settings, never database IDs or Search Console credentials. */
5
+ const text = z.string().max(10000).nullable().optional()
6
+ const webUrl = z.string().max(400).refine(value => {
7
+ if (!value || /^\/(?!\/)\S*$/.test(value)) return true
8
+ try { const u = new URL(value); return ['http:', 'https:'].includes(u.protocol) && !u.username && !u.password } catch { return false }
9
+ }, 'Expected an http or https URL, or a path on this site.').nullable().optional()
10
+ const image = z.strictObject({ source: z.string().min(1).max(2048) }).nullable().optional()
11
+ export const DocumentSeoSeedSchema = z.strictObject({
12
+ title: z.string().max(400).nullable().optional(), description: z.string().max(400).nullable().optional(), ogTitle: z.string().max(400).nullable().optional(), ogDescription: z.string().max(400).nullable().optional(),
13
+ ogImage: image, canonical: webUrl, noindex: z.boolean().optional(),
14
+ })
15
+ export const SiteSeoSeedSchema = z.strictObject({
16
+ allowIndexing: z.boolean().nullable().optional(), siteName: text, titleTemplate: text,
17
+ description: text, locale: text, ogImage: image, twitterHandle: text,
18
+ twitterCard: z.enum(['summary', 'summary_large_image']).optional(),
19
+ googleVerification: text, bingVerification: text,
20
+ organisation: z.strictObject({
21
+ legalName: text, logo: image, phone: text, email: text, address: text, legacyOpeningHours: text,
22
+ sameAs: z.array(z.string().max(2048)).max(100).optional(),
23
+ openingHours: z.array(z.strictObject({
24
+ day: z.enum(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']),
25
+ closed: z.boolean(), opens: text, closes: text,
26
+ })).max(7).optional(),
27
+ }).nullable().optional(),
28
+ })
29
+ export const SeoSeedsSchema = z.strictObject({
30
+ site: SiteSeoSeedSchema.optional(),
31
+ pages: z.record(z.string(), DocumentSeoSeedSchema).optional(),
32
+ entries: z.record(z.string(), z.record(z.string(), DocumentSeoSeedSchema)).optional(),
33
+ })
34
+ export type DocumentSeoSeed = z.infer<typeof DocumentSeoSeedSchema>
35
+ export type SiteSeoSeed = z.infer<typeof SiteSeoSeedSchema>
36
+ export type SeoSeeds = z.infer<typeof SeoSeedsSchema>
37
+
38
+ /** SEO targets must exist and have a page; no silent loss into unrendered fields. */
39
+ export function seoSeedIssues(seo: SeoSeeds | undefined, manifest: SiteManifest): { path: string; message: string }[] {
40
+ const issues: { path: string; message: string }[] = []
41
+ for (const key of Object.keys(seo?.pages ?? {}))
42
+ if (!Object.hasOwn(manifest.pages, key)) issues.push({ path: `seo/pages/${key}`, message: 'SEO names a page the manifest does not declare.' })
43
+ for (const [collection, entries] of Object.entries(seo?.entries ?? {})) {
44
+ if (!manifest.collections[collection]?.route) issues.push({ path: `seo/entries/${collection}`, message: 'SEO needs a collection with its own page route.' })
45
+ for (const slug of Object.keys(entries))
46
+ if (!manifest.entries[collection]?.some(e => e.slug === slug)) issues.push({ path: `seo/entries/${collection}/${slug}`, message: 'SEO names an entry the manifest does not declare.' })
47
+ }
48
+ return issues
49
+ }
50
+
51
+ /** All portable image sources, with their diagnostic paths, for packaging and seeding. */
52
+ export function seoSeedAssets(seo: SeoSeeds | undefined): { source: string; path: string }[] {
53
+ const out: { source: string; path: string }[] = []
54
+ const add = (value: { source: string } | null | undefined, path: string) => { if (value) out.push({ source: value.source, path }) }
55
+ add(seo?.site?.ogImage, 'seo/site/ogImage')
56
+ add(seo?.site?.organisation?.logo, 'seo/site/organisation/logo')
57
+ for (const [key, doc] of Object.entries(seo?.pages ?? {})) add(doc.ogImage, `seo/pages/${key}/ogImage`)
58
+ for (const [collection, entries] of Object.entries(seo?.entries ?? {}))
59
+ for (const [slug, doc] of Object.entries(entries)) add(doc.ogImage, `seo/entries/${collection}/${slug}/ogImage`)
60
+ return out
61
+ }
@@ -5,18 +5,18 @@ import type { LanguageLinks } from '../languages/routing'
5
5
  import { LanguageProvider } from '../switcher/LanguageSwitcher'
6
6
  import { DraftBlocks } from '../bridge/DraftBlocks'
7
7
 
8
- type BlockItem = { _id: string; _block: string; $path?: string } & Record<string, unknown>
8
+ type BlockItem = { _id: string; _block: string; _hidden?: boolean; $path?: string } & Record<string, unknown>
9
9
  /** What a block declared with `entry:` receives, when the document being rendered is an entry page. */
10
10
  type EntryProp = { id: string; slug?: string; route?: string; fields?: Record<string, unknown> }
11
11
 
12
12
  export function createRender(site: Site) {
13
13
  const Blocks = ({ value, slot, entry }: { value: unknown; slot?: ReactNode; entry?: EntryProp }): ReactElement => {
14
- const items = (Array.isArray(value) ? value : []) as BlockItem[]
14
+ const items = ((Array.isArray(value) ? value : []) as BlockItem[]).filter(item => item._hidden !== true)
15
15
  const renderedItems = items.map((item) => {
16
16
  if (item._block === 'SLOT') return createElement(Fragment, { key: item._id }, slot)
17
17
  const def = Object.hasOwn(site.blocks, item._block) ? site.blocks[item._block] : undefined
18
18
  if (!def) return item.$path ? createElement('div', { key: item._id, 'data-u90-block': item.$path, 'data-u90-missing': item._block }, `Block "${item._block}" was removed`) : null
19
- const { _id, _block, $path, ...fields } = item
19
+ const { _id, _block, _hidden, $path, ...fields } = item
20
20
  // zones arrive rendered: replace each zone-typed field with <Blocks/>, threading `entry` through
21
21
  const props: Record<string, unknown> = { ...fields }
22
22
  for (const [name, decl] of Object.entries(def.fields)) if ('container' in (decl as object) && (decl as { container: string }).container === 'u90/zone') props[name] = createElement(Blocks, { value: fields[name], entry })
@@ -48,7 +48,7 @@ const declOf = (site: Site, d: ReplyDocument): DocumentDecl | null => {
48
48
  export function assemble(site: Site, reply: Reply, opts: AssembleOptions): RouteRead {
49
49
  if (reply.status === 'missing') {
50
50
  if (opts.stale && opts.defaults && declaredCodePage(site, opts.target))
51
- return { ...assemble(site, replyFrom(site, opts.defaults, opts.target, reply.manifest), { ...opts, stale: false, defaults: null }), unavailable: true }
51
+ return { ...assemble(site, replyFrom(site, opts.defaults, opts.target, reply.manifest, opts.draft), { ...opts, stale: false, defaults: null }), unavailable: true }
52
52
  return { status: 'missing', tags: reply.tags }
53
53
  }
54
54
  const d = reply.document
@@ -94,8 +94,8 @@ export function defineSite(input: { singles: Singles; collections: Record<string
94
94
  for (const [k, c] of Object.entries(input.collections)) if (c.route) claim(c.route, `collection ${k}`)
95
95
  for (const [k, t] of Object.entries(input.pageTypes)) claim(t.route, `page type ${k}`)
96
96
  const schema: Schema = {
97
- blocks: Object.fromEntries([...blocks.values()].map((b) => [b.name, { name: b.name, label: b.label, fields: strip(b.fields), ...(b.entry ? { entry: b.entry.name } : {}), order: Object.keys(b.fields) } satisfies BlockDecl])),
98
- collections: Object.fromEntries(Object.values(input.collections).map((c) => [c.name, { name: c.name, label: c.label, fields: strip(c.fields), ...(c.template ? { template: JSON.parse(JSON.stringify(c.template.zone)) } : {}), ...(c.defaultSort ? { defaultSort: c.defaultSort } : {}), order: Object.keys(c.fields) } satisfies CollectionDecl])),
97
+ 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
+ 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
99
  singles: Object.fromEntries(Object.values(input.singles).map((s) => [s.key, { key: s.key, field: JSON.parse(JSON.stringify(s.decl)) }])),
100
100
  }
101
101
  // Finding 1: seed entries were checked only when seeded, at the platform or in the defaults store.
@@ -6,12 +6,12 @@ export const humanise = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2
6
6
 
7
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
8
  Object.fromEntries(Object.entries(map).map(([key, decl]) => [key, { key, decl }])) as never
9
- export const collection = <F extends Fields>(name: string, o: { label?: string; fields: F; route?: string; defaultSort?: CollectionDef['defaultSort']; entries?: EntrySeed[] }): CollectionDef<F> =>
10
- ({ kind: 'collection', name, label: o.label ?? humanise(name), fields: o.fields, route: o.route, defaultSort: o.defaultSort, ...(o.entries ? { entries: o.entries } : {}) })
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
+ ({ 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 =>
12
12
  ({ ...c, template: { zone: zone as ZoneDecl, layout: o.layout } })
13
- export const block = <F extends Fields, C extends CollectionDef | undefined = undefined>(name: string, o: { label?: string; fields: F; component: unknown; entry?: C }): BlockDef<F, C> =>
14
- ({ kind: 'block', name, label: o.label ?? humanise(name), fields: o.fields, component: o.component, entry: o.entry })
13
+ export const block = <F extends Fields, C extends CollectionDef | undefined = undefined>(name: string, o: { label?: string; fields: F; component: unknown; entry?: C; shared?: BlockDef['shared'] }): BlockDef<F, C> =>
14
+ ({ kind: 'block', name, label: o.label ?? humanise(name), fields: o.fields, component: o.component, entry: o.entry, ...(o.shared ? { shared: o.shared } : {}) })
15
15
  export const layout = (o: { default?: boolean; parent?: string }, entries: Array<BlockDef | typeof SLOT>): LayoutDef => ({ kind: 'layout', ...o, entries })
16
16
  export const page = (o: { route: string; layout?: string; blocks: Decl<unknown> }): PageDef => ({ kind: 'page', route: o.route, layout: o.layout, blocks: o.blocks as ZoneDecl })
17
17
  export const pageType = (o: { label: string; route: string; layout?: string; blocks: Decl<unknown> }): PageTypeDef => ({ kind: 'pageType', ...o, blocks: o.blocks as ZoneDecl })
@@ -25,14 +25,14 @@ export const number = (o: Common & { default?: number; integer?: boolean; min?:
25
25
  export const boolean = (o: Common & { default?: boolean } = {}) => leaf<BooleanRead>('u90/boolean', o, {})
26
26
  export const choice = (values: Array<string | { value: string; label: string }>, o: Common & { default?: string | string[]; multiple?: boolean } = {}) =>
27
27
  leaf<ChoiceReadValue>('u90/choice', o, { values: values.map((v) => (typeof v === 'string' ? { value: v, label: v[0].toUpperCase() + v.slice(1) } : v)), multiple: o.multiple })
28
- export const relation = (to: string, o: Common & { default?: unknown; many?: boolean; unique?: boolean; inverseOf?: string; depth?: number } = {}) =>
28
+ export const relation = (to: string, o: Common & { default?: unknown; many?: boolean; unique?: boolean; inverseOf?: string; depth?: number; previewFields?: string[] } = {}) =>
29
29
  leaf<EntryRead | EntryRead[] | null>('u90/relation', o, {
30
- to, inverseOf: o.inverseOf, depth: o.depth,
30
+ to, inverseOf: o.inverseOf, depth: o.depth, previewFields: o.previewFields,
31
31
  cardinality: o.inverseOf ? (o.many ? 'many-to-many' : o.unique ? 'one-to-one' : 'one-to-many') : o.many ? 'many-to-many' : o.unique ? 'one-to-one' : 'many-to-one',
32
32
  })
33
- export const selection = (to: string, o: Common & { default?: unknown; many?: boolean; max?: number; depth?: number } = {}) =>
34
- leaf<EntriesRead>('u90/selection', o, { to, many: o.many, max: o.max, depth: o.depth })
35
- export const query = (to: string, o: Common & { default?: unknown } = {}) => leaf<EntriesRead>('u90/query', o, { to })
33
+ export const selection = (to: string, o: Common & { default?: unknown; many?: boolean; max?: number; depth?: number; previewFields?: string[] } = {}) =>
34
+ leaf<EntriesRead>('u90/selection', o, { to, many: o.many, max: o.max, depth: o.depth, previewFields: o.previewFields })
35
+ export const query = (to: string, o: Common & { default?: unknown; previewFields?: string[] } = {}) => leaf<EntriesRead>('u90/query', o, { to, previewFields: o.previewFields })
36
36
  export const single = <R>(s: SingleDef<R>, o: Common = {}): Decl<R> => leaf<R>('u90/single', o, { key: s.key })
37
37
 
38
38
  export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<{ [K in keyof F]: Read<F[K]> }> & { readonly __group: true } =>