@unitninety/site-kit 1.6.1 → 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.
- package/README.md +44 -1
- package/package.json +1 -1
- package/src/bridge/DraftBlocks.tsx +14 -7
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/U90Bridge.tsx +7 -4
- package/src/bridge/client.tsx +3 -2
- package/src/bridge/createBridge.ts +52 -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 +47 -7
- package/src/bridge/registry.ts +9 -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/Entries.tsx +16 -0
- package/src/components/index.ts +1 -0
- 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/query.ts +6 -1
- package/src/elements/leaves/relation.ts +3 -0
- 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 +5 -5
- 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
|
}
|
|
@@ -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),
|
|
@@ -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.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.
|
|
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]
|
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.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
|
-
*
|
|
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 })
|
package/src/site/assemble.ts
CHANGED
|
@@ -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
|
package/src/site/defineSite.ts
CHANGED
|
@@ -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.
|
package/src/site/documents.ts
CHANGED
|
@@ -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 })
|
package/src/site/fields.ts
CHANGED
|
@@ -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 } =>
|