@unitninety/site-kit 1.6.2 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { isLeaf, type CollectionDecl, type FieldDecl } from '../elements/define'
|
|
3
|
+
import { orderedEntries } from '../elements/order'
|
|
4
|
+
|
|
5
|
+
const safeKey = z
|
|
6
|
+
.string()
|
|
7
|
+
.min(1)
|
|
8
|
+
.max(200)
|
|
9
|
+
.refine((key) => !['__proto__', 'constructor', 'prototype'].includes(key), 'Invalid field key.')
|
|
10
|
+
const Field = z.strictObject({ key: safeKey, width: z.enum(['half', 'full']) })
|
|
11
|
+
const Group = z.strictObject({
|
|
12
|
+
id: z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/),
|
|
13
|
+
label: z.string().trim().max(80),
|
|
14
|
+
fields: z.array(Field).max(500),
|
|
15
|
+
})
|
|
16
|
+
/** UI presentation only. No visibility, defaults, validation or site design settings. */
|
|
17
|
+
export const CollectionViewSchema = z.strictObject({
|
|
18
|
+
version: z.literal(1),
|
|
19
|
+
groups: z.array(Group).min(1).max(50),
|
|
20
|
+
})
|
|
21
|
+
export const CollectionViewsSchema = z
|
|
22
|
+
.custom<Record<string, unknown>>(
|
|
23
|
+
(value) =>
|
|
24
|
+
!!value &&
|
|
25
|
+
typeof value === 'object' &&
|
|
26
|
+
!Array.isArray(value) &&
|
|
27
|
+
Object.keys(value).length <= 200 &&
|
|
28
|
+
Object.keys(value).every((key) => !['__proto__', 'constructor', 'prototype'].includes(key)),
|
|
29
|
+
'Invalid collection views.',
|
|
30
|
+
)
|
|
31
|
+
.pipe(z.record(safeKey, CollectionViewSchema))
|
|
32
|
+
export type CollectionView = z.infer<typeof CollectionViewSchema>
|
|
33
|
+
export type CollectionViews = z.infer<typeof CollectionViewsSchema>
|
|
34
|
+
export type CollectionViewField = {
|
|
35
|
+
key: string
|
|
36
|
+
label: string
|
|
37
|
+
width: 'half' | 'full'
|
|
38
|
+
required: boolean
|
|
39
|
+
}
|
|
40
|
+
const humanise = (name: string) =>
|
|
41
|
+
name
|
|
42
|
+
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
43
|
+
.replace(/[-_]/g, ' ')
|
|
44
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
45
|
+
const editable = (field: FieldDecl): boolean =>
|
|
46
|
+
isLeaf(field)
|
|
47
|
+
? !field.lock?.includes('$')
|
|
48
|
+
: field.container !== 'u90/group' || Object.values(field.fields).some(editable)
|
|
49
|
+
|
|
50
|
+
/** Complex schema containers remain single units, with their own nested editors. */
|
|
51
|
+
export function collectionViewFields(decl: CollectionDecl): CollectionViewField[] {
|
|
52
|
+
const fields = orderedEntries(decl.fields, decl.order).filter(([, field]) => editable(field))
|
|
53
|
+
const title = fields.find(([, field]) => isLeaf(field) && field.element === 'u90/text')?.[0]
|
|
54
|
+
return fields.map(([key, field]) => ({
|
|
55
|
+
key,
|
|
56
|
+
label: field.label || humanise(key),
|
|
57
|
+
required:
|
|
58
|
+
key === title ||
|
|
59
|
+
(isLeaf(field)
|
|
60
|
+
? field.required === true
|
|
61
|
+
: field.container === 'u90/list' && (field.min ?? 0) > 0),
|
|
62
|
+
width:
|
|
63
|
+
key === title ||
|
|
64
|
+
!isLeaf(field) ||
|
|
65
|
+
field.options?.multiline === true ||
|
|
66
|
+
['u90/image', 'u90/video', 'u90/file', 'u90/rich-text', 'u90/link', 'u90/query'].includes(
|
|
67
|
+
field.element,
|
|
68
|
+
)
|
|
69
|
+
? 'full'
|
|
70
|
+
: 'half',
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
const MAX_GROUPS = 50
|
|
74
|
+
const MAX_FIELDS_PER_GROUP = 500
|
|
75
|
+
export const defaultCollectionView = (fields: CollectionViewField[]): CollectionView => {
|
|
76
|
+
if (fields.length > MAX_GROUPS * MAX_FIELDS_PER_GROUP)
|
|
77
|
+
throw new Error('This collection has too many fields to configure.')
|
|
78
|
+
return {
|
|
79
|
+
version: 1,
|
|
80
|
+
groups: Array.from(
|
|
81
|
+
{ length: Math.max(1, Math.ceil(fields.length / MAX_FIELDS_PER_GROUP)) },
|
|
82
|
+
(_, i) => ({
|
|
83
|
+
id: i ? `fields_${i}` : 'fields',
|
|
84
|
+
label: '',
|
|
85
|
+
fields: fields
|
|
86
|
+
.slice(i * MAX_FIELDS_PER_GROUP, (i + 1) * MAX_FIELDS_PER_GROUP)
|
|
87
|
+
.map(({ key, width }) => ({ key, width })),
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Saved views from older schemas may not conceal newly added fields or revive locked ones. */
|
|
94
|
+
export function reconcileCollectionView(
|
|
95
|
+
input: unknown,
|
|
96
|
+
fields: CollectionViewField[],
|
|
97
|
+
): CollectionView {
|
|
98
|
+
const parsed = CollectionViewSchema.safeParse(input)
|
|
99
|
+
if (!parsed.success) return defaultCollectionView(fields)
|
|
100
|
+
const available = new Set(fields.map((f) => f.key))
|
|
101
|
+
const seen = new Set<string>()
|
|
102
|
+
const ids = new Set<string>()
|
|
103
|
+
const groups = parsed.data.groups
|
|
104
|
+
.filter((group) => {
|
|
105
|
+
if (ids.has(group.id)) return false
|
|
106
|
+
ids.add(group.id)
|
|
107
|
+
return true
|
|
108
|
+
})
|
|
109
|
+
.map((group) => ({
|
|
110
|
+
...group,
|
|
111
|
+
fields: group.fields.filter((field) => {
|
|
112
|
+
if (!available.has(field.key) || seen.has(field.key)) return false
|
|
113
|
+
seen.add(field.key)
|
|
114
|
+
return true
|
|
115
|
+
}),
|
|
116
|
+
}))
|
|
117
|
+
const missing = fields
|
|
118
|
+
.filter((field) => !seen.has(field.key))
|
|
119
|
+
.map(({ key, width }) => ({ key, width }))
|
|
120
|
+
for (const field of missing) {
|
|
121
|
+
let target = groups.find((group) => !group.label && group.fields.length < MAX_FIELDS_PER_GROUP)
|
|
122
|
+
if (!target && groups.length < MAX_GROUPS) {
|
|
123
|
+
let id = 'fields'
|
|
124
|
+
let suffix = 1
|
|
125
|
+
while (ids.has(id)) id = `fields_${suffix++}`
|
|
126
|
+
ids.add(id)
|
|
127
|
+
target = { id, label: '', fields: [] }
|
|
128
|
+
groups.push(target)
|
|
129
|
+
}
|
|
130
|
+
target ??= groups.find((group) => group.fields.length < MAX_FIELDS_PER_GROUP)
|
|
131
|
+
if (!target) throw new Error('This collection has too many fields to configure.')
|
|
132
|
+
target.fields.push(field)
|
|
133
|
+
}
|
|
134
|
+
return { version: 1, groups }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Writes must account for every field, exactly once. Reads alone reconcile schema drift. */
|
|
138
|
+
export function validateCollectionView(
|
|
139
|
+
input: unknown,
|
|
140
|
+
fields: CollectionViewField[],
|
|
141
|
+
): CollectionView {
|
|
142
|
+
const view = CollectionViewSchema.parse(input)
|
|
143
|
+
if (new Set(view.groups.map((g) => g.id)).size !== view.groups.length)
|
|
144
|
+
throw new Error('Each group needs a unique ID.')
|
|
145
|
+
const keys = view.groups.flatMap((g) => g.fields.map((f) => f.key))
|
|
146
|
+
const expected = new Set(fields.map((f) => f.key))
|
|
147
|
+
if (
|
|
148
|
+
keys.length !== expected.size ||
|
|
149
|
+
new Set(keys).size !== keys.length ||
|
|
150
|
+
keys.some((key) => !expected.has(key))
|
|
151
|
+
)
|
|
152
|
+
throw new Error('Include every editable field once. Reload the view if the schema changed.')
|
|
153
|
+
return view
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function collectionViewsIssues(
|
|
157
|
+
input: CollectionViews | undefined,
|
|
158
|
+
collections: Record<string, CollectionDecl>,
|
|
159
|
+
): { path: string; message: string }[] {
|
|
160
|
+
return Object.entries(input ?? {}).flatMap(([name, view]) => {
|
|
161
|
+
if (!Object.hasOwn(collections, name))
|
|
162
|
+
return [{ path: `collectionViews/${name}`, message: 'This collection is not declared.' }]
|
|
163
|
+
try {
|
|
164
|
+
validateCollectionView(view, collectionViewFields(collections[name]))
|
|
165
|
+
return []
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
path: `collectionViews/${name}`,
|
|
170
|
+
message: error instanceof Error ? error.message : 'Invalid collection view.',
|
|
171
|
+
},
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
}
|
package/src/site/index.ts
CHANGED
|
@@ -12,3 +12,6 @@ export { metadataFor, type PageSeoInput } from '../seo/metadata'
|
|
|
12
12
|
export { organisationJsonLd } from '../seo/jsonLd'
|
|
13
13
|
export { robotsFor } from '../seo/robots'
|
|
14
14
|
export { sitemapFor } from '../seo/sitemap'
|
|
15
|
+
|
|
16
|
+
export { SeoSeedsSchema, DocumentSeoSeedSchema, SiteSeoSeedSchema, seoSeedIssues, seoSeedAssets, type SeoSeeds, type DocumentSeoSeed, type SiteSeoSeed } from '../seo/seeds'
|
|
17
|
+
export { CollectionViewSchema, CollectionViewsSchema, collectionViewFields, defaultCollectionView, reconcileCollectionView, validateCollectionView, collectionViewsIssues, type CollectionView, type CollectionViews, type CollectionViewField } from './formView'
|
package/src/site/replies.ts
CHANGED
|
@@ -22,7 +22,7 @@ const tagOf = (r: Ref): string =>
|
|
|
22
22
|
r.kind === 'media' ? `media:${r.id}` : r.kind === 'page' ? `page:${r.id}` : r.kind === 'single' ? `single:${r.key}` : r.kind === 'entry' ? `entry:${r.id}` : `collection:${r.collection}`
|
|
23
23
|
|
|
24
24
|
/** A query over the store: `where` (eq, neq, in, not-in, is-set), a field `sort`, then the placement's picks and `limit`, which `applyQueryPicks` applies. */
|
|
25
|
-
function runQuery(store: Store, collection: string, q: QueryStored): { ids: Id[]; total: number } {
|
|
25
|
+
function runQuery(store: Store, collection: string, q: QueryStored, draft: boolean): { ids: Id[]; total: number; matches?: Id[] } {
|
|
26
26
|
let rows = [...store.entries.values()].filter((e) => e.collection === collection)
|
|
27
27
|
for (const w of q.where ?? [])
|
|
28
28
|
rows = rows.filter((e) => {
|
|
@@ -31,7 +31,8 @@ function runQuery(store: Store, collection: string, q: QueryStored): { ids: Id[]
|
|
|
31
31
|
})
|
|
32
32
|
const sort = q.sort
|
|
33
33
|
if (sort && sort !== 'manual') rows.sort((a, b) => String(a.fields[sort.field]).localeCompare(String(b.fields[sort.field])) * (sort.dir === 'desc' ? -1 : 1))
|
|
34
|
-
|
|
34
|
+
const matches = rows.map(r => r.id)
|
|
35
|
+
return { ...applyQueryPicks(matches, q, id => store.entries.has(entryKey(collection, id))), ...(draft ? { matches } : {}) }
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
type Found = { doc: ReplyDocument; decl: DocumentDecl }
|
|
@@ -59,7 +60,7 @@ const entryFound = (site: Site, store: Store, collection: string, slug: string):
|
|
|
59
60
|
* lookups they ask for, and the tags the read depends on. The fake answers with it, and a site
|
|
60
61
|
* reading its own defaults goes through it too, so every read is assembled the same way.
|
|
61
62
|
*/
|
|
62
|
-
export function replyFrom(site: Site, store: Store, target: ReplyTarget, manifest: string): Reply {
|
|
63
|
+
export function replyFrom(site: Site, store: Store, target: ReplyTarget, manifest: string, draft = false): Reply {
|
|
63
64
|
let found: Found | null = null
|
|
64
65
|
if ('route' in target) {
|
|
65
66
|
const p = [...store.pages.values()].find((x) => x.route === target.route)
|
|
@@ -96,7 +97,7 @@ export function replyFrom(site: Site, store: Store, target: ReplyTarget, manifes
|
|
|
96
97
|
const queries: DocumentReply['queries'] = {}
|
|
97
98
|
const inverses: DocumentReply['inverses'] = {}
|
|
98
99
|
for (const r of lookups) {
|
|
99
|
-
if (r.kind === 'query') queries[queryKey(r.collection, r.query)] ??= runQuery(store, r.collection, r.query as QueryStored)
|
|
100
|
+
if (r.kind === 'query') queries[queryKey(r.collection, r.query)] ??= runQuery(store, r.collection, r.query as QueryStored, draft)
|
|
100
101
|
if (r.kind === 'inverse')
|
|
101
102
|
inverses[inverseKey(r.collection, r.field, r.id)] ??= [...store.entries.values()]
|
|
102
103
|
.filter((e) => e.collection === r.collection && (Array.isArray(e.fields[r.field]) ? (e.fields[r.field] as unknown[]) : [e.fields[r.field]]).some((v) => String(v) === String(r.id)))
|
package/src/site/types.ts
CHANGED
|
@@ -31,11 +31,12 @@ export type EntrySeed = { slug: string; fields?: Record<string, unknown>; blocks
|
|
|
31
31
|
export type CollectionDef<F extends Fields = Fields> = {
|
|
32
32
|
kind: 'collection'; name: string; label: string; fields: F
|
|
33
33
|
route?: string; defaultSort?: CollectionDecl['defaultSort']
|
|
34
|
+
authoring?: CollectionDecl['authoring']
|
|
34
35
|
template?: { zone: ZoneDecl; layout?: string }
|
|
35
36
|
entries?: EntrySeed[]
|
|
36
37
|
}
|
|
37
38
|
export type BlockDef<F extends Fields = Fields, C extends CollectionDef | undefined = CollectionDef | undefined> = {
|
|
38
|
-
kind: 'block'; name: string; label: string; fields: F; component: unknown; entry?: C
|
|
39
|
+
kind: 'block'; name: string; label: string; fields: F; component: unknown; entry?: C; shared?: BlockDecl['shared']
|
|
39
40
|
}
|
|
40
41
|
export type LayoutDef = { kind: 'layout'; default?: boolean; parent?: string; entries: Array<BlockDef | 'SLOT'> }
|
|
41
42
|
export type PageDef = { kind: 'page'; route: string; layout?: string; blocks: ZoneDecl }
|
|
@@ -73,3 +74,4 @@ export type BlockProps<B> = B extends BlockDef<infer F, infer C>
|
|
|
73
74
|
? Simplify<{ [K in keyof F]: Read<F[K]> } & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
|
|
74
75
|
: never
|
|
75
76
|
export type { FieldDecl, LeafDecl, GroupDecl, ListDecl, ZoneDecl, BlockDecl, CollectionDecl, SingleDecl, Schema }
|
|
77
|
+
export type { CollectionAuthoring, CollectionAuthoringAnchor, CollectionAuthoringSections } from '../elements'
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.6.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.6.3'
|
package/src/site/wire.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type DocumentReply = {
|
|
|
33
33
|
/** By `entryKey(collection, id)`. */
|
|
34
34
|
entries: Record<string, EntryRecord>
|
|
35
35
|
/** By `queryKey(collection, query)`. */
|
|
36
|
-
queries: Record<string, { ids: Id[]; total: number }>
|
|
36
|
+
queries: Record<string, { ids: Id[]; total: number; matches?: Id[] }>
|
|
37
37
|
/** By `inverseKey(collection, field, id)`. */
|
|
38
38
|
inverses: Record<string, Id[]>
|
|
39
39
|
tags: string[]
|
package/src/testing/fake.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { seedCtx, seedEntry, seedSite, type Store } from '../site/store'
|
|
|
6
6
|
|
|
7
7
|
export type { RouteRead }
|
|
8
8
|
const assembled = (site: Site, store: Store, target: ReplyTarget, draft: boolean): RouteRead =>
|
|
9
|
-
assemble(site, replyFrom(site, store, target, ''), { draft, target, stale: false, defaults: null })
|
|
9
|
+
assemble(site, replyFrom(site, store, target, '', draft), { draft, target, stale: false, defaults: null })
|
|
10
10
|
/** A route read against a store, assembled as the site assembles a platform's reply. */
|
|
11
11
|
export const routeRead = (site: Site, store: Store, pathname: string, draft: boolean): RouteRead => assembled(site, store, { route: pathname }, draft)
|
|
12
12
|
/**
|
|
@@ -83,14 +83,14 @@ export async function createFakePlatform(site: Site, store?: Store, opts: { mani
|
|
|
83
83
|
// A site with languages hears which language answered, as the platform says it, and which languages have copy of the document (ruling R2).
|
|
84
84
|
const answer = (reply: Reply) =>
|
|
85
85
|
json(reply.status === 'ok' && chain?.locale ? { ...reply, locale: chain.locale, ...(languages ? { translated: translatedOf(`${reply.document.kind}:${reply.document.id}`) } : {}) } : reply)
|
|
86
|
-
if (p === '/route') return answer(replyFrom(site, view, { route: url.searchParams.get('path') ?? '/' }, manifest))
|
|
87
|
-
if (p.startsWith('/page/')) return answer(replyFrom(site, view, { page: decodeURIComponent(p.slice(6)) }, manifest))
|
|
86
|
+
if (p === '/route') return answer(replyFrom(site, view, { route: url.searchParams.get('path') ?? '/' }, manifest, draft))
|
|
87
|
+
if (p.startsWith('/page/')) return answer(replyFrom(site, view, { page: decodeURIComponent(p.slice(6)) }, manifest, draft))
|
|
88
88
|
if (p.startsWith('/entry/')) {
|
|
89
89
|
// The read client encodes both segments (and a URL's pathname keeps them encoded), so a
|
|
90
90
|
// non-ASCII slug arrives as `caf%C3%A9`; decoded after the split, so an encoded `/` stays in its segment.
|
|
91
91
|
let segments: string[]
|
|
92
92
|
try { segments = p.split('/').slice(2).map(decodeURIComponent) } catch { return json({ error: 'malformed path' }, 400) }
|
|
93
|
-
return answer(replyFrom(site, view, { entry: { collection: segments[0], slug: segments[1] } }, manifest))
|
|
93
|
+
return answer(replyFrom(site, view, { entry: { collection: segments[0], slug: segments[1] } }, manifest, draft))
|
|
94
94
|
}
|
|
95
95
|
if (p === '/site') return opts.failSite ? json({ error: 'site settings unavailable' }, 500) : json(languages ? { ...settings, languages } : settings)
|
|
96
96
|
if (p === '/sitemap') {
|