@unitninety/site-kit 1.6.2 → 1.6.4

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 (44) 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/components/Link.tsx +1 -1
  16. package/src/elements/containers/group.ts +1 -1
  17. package/src/elements/containers/zone.ts +13 -2
  18. package/src/elements/declaration.ts +89 -2
  19. package/src/elements/define.ts +22 -3
  20. package/src/elements/jsonSchema.ts +4 -3
  21. package/src/elements/leaves/link.ts +17 -2
  22. package/src/elements/leaves/query.ts +6 -1
  23. package/src/elements/leaves/relation.ts +3 -0
  24. package/src/elements/leaves/rich-text.ts +32 -16
  25. package/src/elements/leaves/selection.ts +5 -4
  26. package/src/elements/ops/expand.ts +5 -4
  27. package/src/elements/ops/seed.ts +25 -6
  28. package/src/elements/ops/validate.ts +31 -2
  29. package/src/legacy/cli.mjs +2 -2
  30. package/src/legacy/content.ts +1 -1
  31. package/src/seo/robots.ts +8 -8
  32. package/src/seo/seeds.ts +61 -0
  33. package/src/server/render.tsx +3 -3
  34. package/src/site/assemble.ts +1 -1
  35. package/src/site/defineSite.ts +2 -2
  36. package/src/site/documents.ts +4 -4
  37. package/src/site/fields.ts +6 -6
  38. package/src/site/formView.ts +175 -0
  39. package/src/site/index.ts +3 -0
  40. package/src/site/replies.ts +5 -4
  41. package/src/site/types.ts +3 -1
  42. package/src/site/version.ts +1 -1
  43. package/src/site/wire.ts +1 -1
  44. 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
  }
@@ -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
  },
@@ -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),
@@ -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 }[] = []
@@ -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.4 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.4 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.4 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 })