@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.
- package/README.md +26 -1
- package/package.json +1 -1
- package/src/bridge/DraftBlocks.tsx +14 -7
- package/src/bridge/U90Bridge.tsx +7 -4
- package/src/bridge/client.tsx +3 -2
- package/src/bridge/createBridge.ts +47 -12
- package/src/bridge/hooks.tsx +9 -8
- package/src/bridge/index.ts +6 -0
- package/src/bridge/nativeDraft.ts +177 -0
- package/src/bridge/protocol.ts +45 -7
- package/src/bridge/registry.ts +6 -1
- package/src/bridge/selection.ts +134 -0
- package/src/bridge/styles.ts +10 -1
- package/src/bridge/useDraftContent.tsx +151 -0
- package/src/components/Link.tsx +1 -1
- package/src/elements/containers/group.ts +1 -1
- package/src/elements/containers/zone.ts +13 -2
- package/src/elements/declaration.ts +89 -2
- package/src/elements/define.ts +22 -3
- package/src/elements/jsonSchema.ts +4 -3
- package/src/elements/leaves/link.ts +17 -2
- package/src/elements/leaves/query.ts +6 -1
- package/src/elements/leaves/relation.ts +3 -0
- package/src/elements/leaves/rich-text.ts +32 -16
- package/src/elements/leaves/selection.ts +5 -4
- package/src/elements/ops/expand.ts +5 -4
- package/src/elements/ops/seed.ts +25 -6
- package/src/elements/ops/validate.ts +31 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/seo/robots.ts +8 -8
- package/src/seo/seeds.ts +61 -0
- package/src/server/render.tsx +3 -3
- package/src/site/assemble.ts +1 -1
- package/src/site/defineSite.ts +2 -2
- package/src/site/documents.ts +4 -4
- package/src/site/fields.ts +6 -6
- package/src/site/formView.ts +175 -0
- package/src/site/index.ts +3 -0
- package/src/site/replies.ts +5 -4
- package/src/site/types.ts +3 -1
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +1 -1
- package/src/testing/fake.ts +4 -4
package/src/elements/define.ts
CHANGED
|
@@ -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
|
|
33
|
-
|
|
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 =>
|
|
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
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
23
|
+
const FileSeedTo = z.strictObject({ file: z.strictObject({ source: z.string().trim().min(1) }) })
|
|
24
|
+
const LinkNode = (seed = false) => z.object({ type: z.literal('link'), fields: z.object({ to: seed ? z.union([LinkTo, FileSeedTo]) : LinkTo, newTab: z.boolean().default(false) }), children: z.array(z.union([TextNode, LineBreak])), ...common })
|
|
25
|
+
const inlines = (o: O, seed = false) => z.array(o.marks.includes('link') ? z.union([TextNode, LineBreak, LinkNode(seed)]) : z.union([TextNode, LineBreak]))
|
|
26
|
+
const Paragraph = (o: O, seed = false) => z.object({ type: z.literal('paragraph'), children: inlines(o, seed), textFormat: z.int().default(0), ...common })
|
|
27
|
+
const Heading = (o: O, seed = false) => z.object({ type: z.literal('heading'), tag: z.enum(o.headings as [string, ...string[]]), children: inlines(o, seed), ...common })
|
|
28
|
+
const Quote = (o: O, seed = false) => z.object({ type: z.literal('quote'), children: inlines(o, seed), ...common })
|
|
29
|
+
const ListItem = (o: O, seed = false) => z.object({ type: z.literal('listitem'), children: inlines(o, seed), value: z.int().default(1), ...common })
|
|
30
|
+
const List = (o: O, seed = false) => z.object({ type: z.literal('list'), listType: z.enum(['bullet', 'number']), tag: z.enum(['ul', 'ol']), start: z.int().default(1), children: z.array(ListItem(o, seed)), ...common })
|
|
31
|
+
const blockSchemas = (o: O, seed = false) => {
|
|
32
|
+
const by = { paragraph: Paragraph(o, seed), heading: Heading(o, seed), list: List(o, seed), quote: Quote(o, seed) }
|
|
32
33
|
const chosen = o.blocks.map((b) => by[b])
|
|
33
34
|
return chosen.length === 1 ? chosen[0] : z.union(chosen as z.ZodTypeAny[] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]])
|
|
34
35
|
}
|
|
@@ -44,9 +45,9 @@ const eachLink = (node: Node, f: (n: Node) => void) => {
|
|
|
44
45
|
node.children?.forEach((c) => eachLink(c, f))
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const stored = (o: O) =>
|
|
48
|
+
const stored = (o: O, seed = false) =>
|
|
48
49
|
z
|
|
49
|
-
.object({ root: z.object({ type: z.literal('root'), children: z.array(blockSchemas(o)), ...common }) })
|
|
50
|
+
.object({ root: z.object({ type: z.literal('root'), children: z.array(blockSchemas(o, seed)), ...common }) })
|
|
50
51
|
.superRefine((v, ctx) => {
|
|
51
52
|
const root = v.root as unknown as Node
|
|
52
53
|
if (o.inline && (root.children!.length !== 1 || root.children![0].type !== 'paragraph'))
|
|
@@ -70,10 +71,16 @@ export const richTextToPlain = (v: Stored): string =>
|
|
|
70
71
|
v ? (v.root.children as unknown as Node[]).map((b) => { const parts: string[] = []; eachText(b, [], (n) => parts.push(n.text ?? '')); return parts.join('') }).join('\n') : ''
|
|
71
72
|
|
|
72
73
|
export type RichTextRead = ({ root: unknown; $path?: string }) | null
|
|
73
|
-
const expand = (v: Stored, o: O, ctx: ExpandCtx): RichTextRead => {
|
|
74
|
+
const expand = (v: Stored, o: O, ctx: ExpandCtx, seed = false): RichTextRead => {
|
|
74
75
|
if (v === null) return null
|
|
75
76
|
const root = structuredClone(v.root) as unknown as Node
|
|
76
|
-
eachLink(root, (n) => {
|
|
77
|
+
eachLink(root, (n) => {
|
|
78
|
+
const fields = n.fields as { to: z.infer<typeof LinkTo> | z.infer<typeof FileSeedTo> }
|
|
79
|
+
if (seed && 'file' in fields.to && typeof fields.to.file === 'object') {
|
|
80
|
+
const source = fields.to.file.source
|
|
81
|
+
Object.assign(fields, { to: { file: source }, href: source, status: 'ready' })
|
|
82
|
+
} else Object.assign(fields, hrefOf(fields.to as z.infer<typeof LinkTo>, ctx))
|
|
83
|
+
})
|
|
77
84
|
const split = (node: Node) => {
|
|
78
85
|
if (!node.children) return
|
|
79
86
|
node.children.forEach(split)
|
|
@@ -118,7 +125,7 @@ export const richText = defineElement({
|
|
|
118
125
|
label: 'Rich text',
|
|
119
126
|
placement: 'any',
|
|
120
127
|
options: RichTextOptions,
|
|
121
|
-
seed: stored,
|
|
128
|
+
seed: (o) => stored(o, true),
|
|
122
129
|
stored,
|
|
123
130
|
shape: () => SHAPE,
|
|
124
131
|
read: () => z.object({ root: z.unknown(), $path: z.string().optional() }).nullable(),
|
|
@@ -132,12 +139,21 @@ export const richText = defineElement({
|
|
|
132
139
|
const to = (n.fields as { to: z.infer<typeof LinkTo> }).to
|
|
133
140
|
if ('page' in to) refs.push({ kind: 'page', id: to.page })
|
|
134
141
|
else if ('entry' in to) refs.push({ kind: 'entry', collection: to.entry.collection, id: to.entry.id, depth: 0 })
|
|
142
|
+
else if ('file' in to) refs.push({ kind: 'media', id: to.file })
|
|
135
143
|
})
|
|
136
144
|
return refs
|
|
137
145
|
},
|
|
138
|
-
toStored: async (seed) =>
|
|
146
|
+
toStored: async (seed, _o, ctx) => {
|
|
147
|
+
if (seed === null) return null
|
|
148
|
+
const copy = structuredClone(seed)
|
|
149
|
+
await Promise.all(linkNodes(copy).map(async (node) => {
|
|
150
|
+
const fields = node.fields as { to: z.infer<typeof LinkTo> | z.infer<typeof FileSeedTo> }
|
|
151
|
+
if ('file' in fields.to && typeof fields.to.file === 'object') fields.to = { file: await ctx.importAsset(fields.to.file.source) }
|
|
152
|
+
}))
|
|
153
|
+
return copy
|
|
154
|
+
},
|
|
139
155
|
expand,
|
|
140
|
-
expandSeed: expand,
|
|
156
|
+
expandSeed: (s, o, ctx) => expand(s, o, ctx, true),
|
|
141
157
|
toText: richTextToPlain,
|
|
142
158
|
translatable: (v) => {
|
|
143
159
|
const out: { path: (string | number)[]; value: string }[] = []
|
|
@@ -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
|
|
9
|
-
export
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
package/src/elements/ops/seed.ts
CHANGED
|
@@ -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, ...(
|
|
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
|
-
|
|
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) =>
|
|
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(
|
|
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?.
|
|
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)
|
package/src/legacy/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `npx --yes @unitninety/u90@1.0.
|
|
5
|
+
* `npx --yes @unitninety/u90@1.0.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.
|
|
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]
|
package/src/legacy/content.ts
CHANGED
|
@@ -360,7 +360,7 @@ export const createContentClient = ({
|
|
|
360
360
|
: !platformUrl
|
|
361
361
|
? 'PLATFORM_URL is unset'
|
|
362
362
|
: 'PAYLOAD_API_KEY is unset'
|
|
363
|
-
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.
|
|
363
|
+
}. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
})
|
package/src/seo/seeds.ts
ADDED
|
@@ -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
|
+
}
|
package/src/server/render.tsx
CHANGED
|
@@ -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 })
|