@unitninety/site-kit 0.0.0-stage → 1.6.2
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 +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { elements, expand, type DocumentDecl, type FieldDecl, type Resolved, type Schema } from '../elements'
|
|
2
|
+
import { replyFrom, type ReplyTarget } from './replies'
|
|
3
|
+
import type { Store } from './store'
|
|
4
|
+
import type { Site } from './types'
|
|
5
|
+
import type { Reply, ReplyDocument } from './wire'
|
|
6
|
+
|
|
7
|
+
/** A document's SEO settings as a render uses them: `ogImage` resolved to its URL. */
|
|
8
|
+
export type RouteSeo = { title?: string; description?: string; ogTitle?: string; ogDescription?: string; ogImage?: string; canonical?: string; noindex?: boolean }
|
|
9
|
+
/** What `u90.render` draws: a document's read values, its layouts' read values, and the tags its cache entry carries. */
|
|
10
|
+
export type RouteRead = {
|
|
11
|
+
status: 'ok' | 'missing'
|
|
12
|
+
document?: { kind: 'page' | 'entry'; id: string; slug: string; collection?: string; key?: string; pageType?: string; title?: string; seo: RouteSeo | null; layoutChain: string[] }
|
|
13
|
+
read?: unknown
|
|
14
|
+
layouts?: Record<string, unknown>
|
|
15
|
+
tags: string[]
|
|
16
|
+
/** Rendered from the code's defaults, not from the platform. */
|
|
17
|
+
unavailable?: boolean
|
|
18
|
+
/** The reply's `translated`, as the platform answered it: a tenant with languages only. The metadata lists only these languages, the default and the original (ruling R2). */
|
|
19
|
+
translated?: string[]
|
|
20
|
+
}
|
|
21
|
+
export type AssembleOptions = {
|
|
22
|
+
draft: boolean
|
|
23
|
+
target: ReplyTarget
|
|
24
|
+
/** True when the reply's `manifest` differs from the running code's: the platform has not caught up with a deploy. */
|
|
25
|
+
stale: boolean
|
|
26
|
+
/** The code's defaults, needed only when `stale`. */
|
|
27
|
+
defaults: Store | null
|
|
28
|
+
/** Draft mode only: the language a translated frame reads in, which internal link hrefs keep. Absent, no href changes. */
|
|
29
|
+
draftLocale?: string
|
|
30
|
+
/** The address prefix of the language the reply is read in (`/fr`): page and entry routes carry it. Absent, no route changes. */
|
|
31
|
+
localePrefix?: string
|
|
32
|
+
}
|
|
33
|
+
const declaredCodePage = (site: Site, t: ReplyTarget) =>
|
|
34
|
+
'page' in t ? Object.hasOwn(site.pages, t.page) : 'route' in t ? Object.values(site.pages).some((p) => p.route === t.route) : false
|
|
35
|
+
const declOf = (site: Site, d: ReplyDocument): DocumentDecl | null => {
|
|
36
|
+
if (d.kind === 'entry') return d.collection && Object.hasOwn(site.schema.collections, d.collection) ? { kind: 'entry', collection: d.collection } : null
|
|
37
|
+
if (d.key) return Object.hasOwn(site.pages, d.key) ? { kind: 'page', blocks: site.pages[d.key].blocks } : null
|
|
38
|
+
return d.pageType && Object.hasOwn(site.pageTypes, d.pageType) ? { kind: 'page', blocks: site.pageTypes[d.pageType].blocks } : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A reply expanded with the running code's declarations, never the platform's copy of them, so a
|
|
43
|
+
* deploy never waits for the platform to pull its manifest. A field the stored document lacks reads
|
|
44
|
+
* as its declared default, and a `renamedFrom` field reads from its old key. Only while `stale`, a
|
|
45
|
+
* code page, layout or Single the code declares and the reply lacks comes from the code's defaults.
|
|
46
|
+
* A document the running code does not declare (a removed code page, page type or collection) is missing.
|
|
47
|
+
*/
|
|
48
|
+
export function assemble(site: Site, reply: Reply, opts: AssembleOptions): RouteRead {
|
|
49
|
+
if (reply.status === 'missing') {
|
|
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 }
|
|
52
|
+
return { status: 'missing', tags: reply.tags }
|
|
53
|
+
}
|
|
54
|
+
const d = reply.document
|
|
55
|
+
const docDecl = declOf(site, d)
|
|
56
|
+
if (!docDecl) return { status: 'missing', tags: reply.tags }
|
|
57
|
+
|
|
58
|
+
// A Single the running code declares expands with that declaration, and any other with the one the reply carries —
|
|
59
|
+
// unless the reply's declaration names an element this registry does not know (a platform newer than this site),
|
|
60
|
+
// in which case that Single is skipped and reads as absent rather than throwing on every read.
|
|
61
|
+
const resolvable = (d: FieldDecl): boolean =>
|
|
62
|
+
'element' in d ? Object.hasOwn(elements, d.element) : d.container === 'u90/group' ? Object.values(d.fields).every(resolvable) : d.container === 'u90/list' ? resolvable(d.item) : false
|
|
63
|
+
const carried = Object.fromEntries(
|
|
64
|
+
Object.entries(reply.singles).filter(([k, s]) => !Object.hasOwn(site.schema.singles, k) && s.declaration && resolvable(s.declaration)).map(([k, s]) => [k, { key: k, field: s.declaration as FieldDecl }]),
|
|
65
|
+
)
|
|
66
|
+
const schema: Schema = Object.keys(carried).length ? { ...site.schema, singles: { ...site.schema.singles, ...carried } } : site.schema
|
|
67
|
+
const base: Resolved = {
|
|
68
|
+
media: new Map(Object.entries(reply.media)),
|
|
69
|
+
pages: new Map(Object.entries(reply.pages)),
|
|
70
|
+
entries: new Map(Object.entries(reply.entries)),
|
|
71
|
+
queries: new Map(Object.entries(reply.queries)),
|
|
72
|
+
inverses: new Map(Object.entries(reply.inverses)),
|
|
73
|
+
}
|
|
74
|
+
// A draft reply of a translated language lists, by document, the overlay keys that fell back; a published reply carries none.
|
|
75
|
+
const fallbacksOf = (docKey: string) => (opts.draft ? reply.fallbacks?.[docKey] : undefined)
|
|
76
|
+
const storedSingles = new Map<string, unknown>(Object.entries(reply.singles).map(([k, s]) => [k, s.stored]))
|
|
77
|
+
if (opts.stale && opts.defaults) for (const [k, s] of opts.defaults.singles) if (!storedSingles.has(k)) storedSingles.set(k, s.doc)
|
|
78
|
+
const singles = new Map<string, unknown>()
|
|
79
|
+
for (const [key, doc] of storedSingles) if (Object.hasOwn(schema.singles, key)) singles.set(key, expand(schema, { kind: 'single', key }, doc, base, { at: `single:${key}`, draft: opts.draft, draftLocale: opts.draftLocale, localePrefix: opts.localePrefix, fallbacks: fallbacksOf(`single:${key}`) }))
|
|
80
|
+
const resolved: Resolved = { ...base, singles }
|
|
81
|
+
|
|
82
|
+
const layouts: Record<string, unknown> = {}
|
|
83
|
+
const chain: string[] = []
|
|
84
|
+
const layoutDecl: DocumentDecl = { kind: 'layout', blocks: { container: 'u90/zone', allow: Object.keys(schema.blocks) } }
|
|
85
|
+
for (const key of d.layoutChain) {
|
|
86
|
+
const l = (Object.hasOwn(reply.layouts, key) ? reply.layouts[key] : undefined) ?? (opts.stale ? opts.defaults?.layouts.get(key)?.doc : undefined)
|
|
87
|
+
if (l === undefined) continue
|
|
88
|
+
chain.push(key)
|
|
89
|
+
layouts[key] = expand(schema, layoutDecl, l, resolved, { at: `layout:${key}`, draft: opts.draft, draftLocale: opts.draftLocale, localePrefix: opts.localePrefix, fallbacks: fallbacksOf(`layout:${key}`) })
|
|
90
|
+
}
|
|
91
|
+
const s = d.seo
|
|
92
|
+
const ogImage = s?.ogImage != null ? reply.media[String(s.ogImage)]?.url : undefined
|
|
93
|
+
const seo: RouteSeo | null = s
|
|
94
|
+
? { ...(s.title ? { title: s.title } : {}), ...(s.description ? { description: s.description } : {}), ...(s.ogTitle ? { ogTitle: s.ogTitle } : {}), ...(s.ogDescription ? { ogDescription: s.ogDescription } : {}), ...(ogImage ? { ogImage } : {}), ...(s.canonical ? { canonical: s.canonical } : {}), ...(s.noindex ? { noindex: true } : {}) }
|
|
95
|
+
: null
|
|
96
|
+
return {
|
|
97
|
+
status: 'ok',
|
|
98
|
+
document: {
|
|
99
|
+
kind: d.kind, id: d.id, slug: d.slug,
|
|
100
|
+
...(d.collection ? { collection: d.collection } : {}), ...(d.key ? { key: d.key } : {}), ...(d.pageType ? { pageType: d.pageType } : {}), ...(d.title ? { title: d.title } : {}),
|
|
101
|
+
seo,
|
|
102
|
+
layoutChain: chain,
|
|
103
|
+
},
|
|
104
|
+
read: expand(schema, docDecl, d.stored, resolved, { at: `${d.kind}:${d.id}`, draft: opts.draft, draftLocale: opts.draftLocale, localePrefix: opts.localePrefix, fallbacks: fallbacksOf(`${d.kind}:${d.id}`) }),
|
|
105
|
+
layouts,
|
|
106
|
+
tags: reply.tags,
|
|
107
|
+
...(reply.translated ? { translated: reply.translated } : {}),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { seedIssues, validateSchema, type BlockDecl, type CollectionDecl, type FieldDecl, type Issue, type Schema, type SeedKnown, type ZoneDecl } from '../elements'
|
|
2
|
+
import { SLOT } from './documents'
|
|
3
|
+
import type { BlockDef, CollectionDef, LayoutDef, PageDef, PageTypeDef, Seo, Singles, Site, SiteManifest } from './types'
|
|
4
|
+
import type { KnowledgePolicyV1 } from '../features/contracts/knowledgePolicy'
|
|
5
|
+
import { SITE_KIT_VERSION } from './version'
|
|
6
|
+
|
|
7
|
+
export class SiteDeclarationError extends Error {
|
|
8
|
+
constructor(public issues: Issue[]) { super(issues.map((i) => `${i.path}: ${i.message}`).join('\n')); this.name = 'SiteDeclarationError' }
|
|
9
|
+
}
|
|
10
|
+
const blocksOf = (decl: FieldDecl): BlockDef[] => (decl as { __blocks?: BlockDef[] }).__blocks ?? []
|
|
11
|
+
/**
|
|
12
|
+
* Every block reachable from a declaration, recursively through groups, lists and nested zones.
|
|
13
|
+
* `add` records a block and says whether it is new, so a block reached twice is walked once.
|
|
14
|
+
*/
|
|
15
|
+
const collect = (decl: FieldDecl, add: (b: BlockDef) => boolean) => {
|
|
16
|
+
if ('element' in decl) return
|
|
17
|
+
if (decl.container === 'u90/group') return Object.values(decl.fields).forEach((f) => collect(f, add))
|
|
18
|
+
if (decl.container === 'u90/list') return collect(decl.item, add)
|
|
19
|
+
for (const b of blocksOf(decl)) if (add(b)) Object.values(b.fields).forEach((f) => collect(f as FieldDecl, add))
|
|
20
|
+
}
|
|
21
|
+
const issue = (path: string, code: string, message: string): Issue => ({ path, code, message, severity: 'error' })
|
|
22
|
+
/**
|
|
23
|
+
* R37: v1's renderer (`server/render.tsx`) turns a block's own zone fields into rendered children and
|
|
24
|
+
* nothing else — a zone inside a `group()` or a `list()` would reach the component as raw block data
|
|
25
|
+
* and fail at request time ("Objects are not valid as a React child"), after the build had passed.
|
|
26
|
+
* The registry can express a nested zone; v1 does not render one, so it is refused here, by path.
|
|
27
|
+
* `direct` is true only for a block's own field.
|
|
28
|
+
*/
|
|
29
|
+
const misplacedZones = (decl: FieldDecl, at: string, direct: boolean, into: Issue[]) => {
|
|
30
|
+
if ('element' in decl) return
|
|
31
|
+
if (decl.container === 'u90/zone') { if (!direct) into.push(issue(at, 'zone-placement', 'a zone must be a direct block field in v1')); return }
|
|
32
|
+
if (decl.container === 'u90/group') return Object.entries(decl.fields).forEach(([k, f]) => misplacedZones(f, `${at}.fields.${k}`, false, into))
|
|
33
|
+
if (decl.container === 'u90/list') return misplacedZones(decl.item, `${at}.item`, false, into)
|
|
34
|
+
}
|
|
35
|
+
const PAGE_KEY = /^[a-z][a-zA-Z0-9_-]*$/
|
|
36
|
+
const strip = (fields: Record<string, unknown>) => Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, JSON.parse(JSON.stringify(v))])) as Record<string, FieldDecl>
|
|
37
|
+
|
|
38
|
+
export function defineSite(input: { singles: Singles; collections: Record<string, CollectionDef>; layouts: Record<string, LayoutDef>; pages: Record<string, PageDef>; pageTypes: Record<string, PageTypeDef>; seo?: Seo; knowledge?: KnowledgePolicyV1 }): Site {
|
|
39
|
+
const defaults = Object.entries(input.layouts).filter(([, l]) => l.default)
|
|
40
|
+
if (defaults.length !== 1) throw new Error(`A site needs exactly one default layout; found ${defaults.length}.`)
|
|
41
|
+
const defaultLayout = defaults[0][0]
|
|
42
|
+
// A page reference holds a declared page's key or a stored page's id, and the two are told apart by
|
|
43
|
+
// shape alone (`isDocumentId`: a stored id starts with a digit). A key that could look like an id
|
|
44
|
+
// would be read as one, so every key starts with a letter.
|
|
45
|
+
for (const k of Object.keys(input.pages)) {
|
|
46
|
+
if (!/^[a-z]/.test(k)) throw new Error(`Page key "${k}" must start with a lower-case letter.`)
|
|
47
|
+
if (!PAGE_KEY.test(k)) throw new Error(`Page key "${k}" may hold only letters, digits, "_" and "-".`)
|
|
48
|
+
}
|
|
49
|
+
const issues: Issue[] = []
|
|
50
|
+
// Layouts are named by string, so a typo would otherwise surface only at request time — and a
|
|
51
|
+
// parent cycle would never surface at all: the layout chain would be walked forever.
|
|
52
|
+
const declared = (name: string | undefined, at: string) => { if (name !== undefined && !Object.hasOwn(input.layouts, name)) issues.push(issue(at, 'unknown-layout', `layout "${name}" is not declared`)) }
|
|
53
|
+
for (const [k, l] of Object.entries(input.layouts)) declared(l.parent, `layouts.${k}.parent`)
|
|
54
|
+
for (const [k, p] of Object.entries(input.pages)) declared(p.layout, `pages.${k}.layout`)
|
|
55
|
+
for (const [k, t] of Object.entries(input.pageTypes)) declared(t.layout, `pageTypes.${k}.layout`)
|
|
56
|
+
for (const c of Object.values(input.collections)) declared(c.template?.layout, `collections.${c.name}.template.layout`)
|
|
57
|
+
const inCycle = new Set<string>()
|
|
58
|
+
for (const [k, l] of Object.entries(input.layouts)) {
|
|
59
|
+
if (inCycle.has(k)) continue
|
|
60
|
+
const chain = [k]
|
|
61
|
+
for (let p = l.parent; p !== undefined && Object.hasOwn(input.layouts, p); p = input.layouts[p].parent) {
|
|
62
|
+
if (p === k) { issues.push(issue(`layouts.${k}.parent`, 'layout-cycle', `layout parents form a cycle: ${[...chain, k].join(' → ')}`)); chain.forEach((c) => inCycle.add(c)); break }
|
|
63
|
+
if (chain.includes(p)) break // a cycle further up, reported from one of its own layouts
|
|
64
|
+
chain.push(p)
|
|
65
|
+
}
|
|
66
|
+
// No SLOT and the page's own blocks have nowhere to go; two and they render twice.
|
|
67
|
+
const slots = l.entries.filter((e) => e === SLOT).length
|
|
68
|
+
if (slots !== 1) issues.push(issue(`layouts.${k}`, 'layout-slot', `a layout needs exactly one SLOT; found ${slots}`))
|
|
69
|
+
}
|
|
70
|
+
// Blocks are found by walking the site and keyed by name, so two different declarations under one
|
|
71
|
+
// name would otherwise resolve silently to whichever the walk met first (or last).
|
|
72
|
+
const blocks = new Map<string, BlockDef>()
|
|
73
|
+
const add = (b: BlockDef): boolean => {
|
|
74
|
+
const seen = blocks.get(b.name)
|
|
75
|
+
if (seen === b) return false
|
|
76
|
+
if (seen) { issues.push(issue(`blocks.${b.name}`, 'duplicate-block', `two blocks named "${b.name}" are declared; each block needs its own name`)); return false }
|
|
77
|
+
blocks.set(b.name, b)
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
for (const l of Object.values(input.layouts)) for (const e of l.entries) if (e !== SLOT && add(e)) Object.values(e.fields).forEach((f) => collect(f as FieldDecl, add))
|
|
81
|
+
for (const p of [...Object.values(input.pages), ...Object.values(input.pageTypes)]) collect(p.blocks, add)
|
|
82
|
+
for (const c of Object.values(input.collections)) if (c.template) collect(c.template.zone, add)
|
|
83
|
+
for (const b of blocks.values()) for (const [f, d] of Object.entries(b.fields)) misplacedZones(d as FieldDecl, `blocks.${b.name}.fields.${f}`, true, issues)
|
|
84
|
+
for (const c of Object.values(input.collections)) for (const [f, d] of Object.entries(c.fields)) misplacedZones(d as FieldDecl, `collections.${c.name}.fields.${f}`, false, issues)
|
|
85
|
+
for (const s of Object.values(input.singles)) misplacedZones(s.decl, `singles.${s.key}.field`, false, issues)
|
|
86
|
+
if (issues.length) throw new SiteDeclarationError(issues)
|
|
87
|
+
for (const c of Object.values(input.collections)) {
|
|
88
|
+
const slugs = new Set<string>()
|
|
89
|
+
for (const e of c.entries ?? []) { if (slugs.has(e.slug)) throw new Error(`Entry "${e.slug}" is declared twice in collection ${c.name}.`); slugs.add(e.slug) }
|
|
90
|
+
}
|
|
91
|
+
const routes = new Map<string, string>()
|
|
92
|
+
const claim = (route: string, by: string) => { if (routes.has(route)) throw new Error(`Route ${route} is declared by both ${routes.get(route)} and ${by}.`); routes.set(route, by) }
|
|
93
|
+
for (const [k, p] of Object.entries(input.pages)) claim(p.route, `page ${k}`)
|
|
94
|
+
for (const [k, c] of Object.entries(input.collections)) if (c.route) claim(c.route, `collection ${k}`)
|
|
95
|
+
for (const [k, t] of Object.entries(input.pageTypes)) claim(t.route, `page type ${k}`)
|
|
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])),
|
|
99
|
+
singles: Object.fromEntries(Object.values(input.singles).map((s) => [s.key, { key: s.key, field: JSON.parse(JSON.stringify(s.decl)) }])),
|
|
100
|
+
}
|
|
101
|
+
// Finding 1: seed entries were checked only when seeded, at the platform or in the defaults store.
|
|
102
|
+
const known: SeedKnown = {
|
|
103
|
+
page: (key) => Object.hasOwn(input.pages, key),
|
|
104
|
+
entry: (collection, key) => (Object.values(input.collections).find((c) => c.name === collection)?.entries ?? []).some((e) => e.slug === key),
|
|
105
|
+
}
|
|
106
|
+
const badSeeds = Object.values(input.collections).flatMap((c) => (c.entries ?? []).flatMap((e) => seedIssues(schema, { kind: 'entry', collection: c.name }, { fields: e.fields, blocks: e.blocks }, `collections.${c.name}.entries.${e.slug}`, known)))
|
|
107
|
+
if (badSeeds.length) throw new SiteDeclarationError(badSeeds)
|
|
108
|
+
return {
|
|
109
|
+
...(input.knowledge !== undefined ? { knowledge: input.knowledge } : {}),
|
|
110
|
+
singles: input.singles, collections: input.collections, blocks: Object.fromEntries(blocks), schema, seo: input.seo ?? {}, defaultLayout,
|
|
111
|
+
layouts: Object.fromEntries(Object.entries(input.layouts).map(([k, l]) => [k, { default: !!l.default, parent: l.parent, blocks: l.entries.map((e) => (e === SLOT ? 'SLOT' : e.name)) }])),
|
|
112
|
+
pages: Object.fromEntries(Object.entries(input.pages).map(([k, p]) => [k, { route: p.route, layout: p.layout ?? defaultLayout, blocks: JSON.parse(JSON.stringify(p.blocks)) as ZoneDecl }])),
|
|
113
|
+
pageTypes: Object.fromEntries(Object.entries(input.pageTypes).map(([k, t]) => [k, { label: t.label, route: t.route, layout: t.layout ?? defaultLayout, blocks: JSON.parse(JSON.stringify(t.blocks)) as ZoneDecl }])),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function manifestFrom(site: Site): SiteManifest {
|
|
118
|
+
const r = validateSchema(site.schema)
|
|
119
|
+
if (!r.ok) throw new SiteDeclarationError(r.issues)
|
|
120
|
+
const routes = [...Object.values(site.pages).map((p) => p.route), ...Object.values(site.collections).flatMap((c) => (c.route ? [c.route] : [])), ...Object.values(site.pageTypes).map((t) => t.route)]
|
|
121
|
+
const entries = Object.fromEntries(Object.values(site.collections).flatMap((c) => (c.entries?.length ? [[c.name, c.entries]] : [])))
|
|
122
|
+
const collections = Object.fromEntries(Object.values(site.collections).map((c) => [c.name, { ...(c.route ? { route: c.route } : {}), ...(c.template?.layout ? { layout: c.template.layout } : {}) }]))
|
|
123
|
+
const order = {
|
|
124
|
+
blocks: Object.keys(site.schema.blocks),
|
|
125
|
+
collections: Object.keys(site.schema.collections),
|
|
126
|
+
singles: Object.keys(site.schema.singles),
|
|
127
|
+
pages: Object.keys(site.pages),
|
|
128
|
+
pageTypes: Object.keys(site.pageTypes),
|
|
129
|
+
layouts: Object.keys(site.layouts),
|
|
130
|
+
}
|
|
131
|
+
return { ...(site.knowledge !== undefined ? { knowledge: site.knowledge } : {}), schema: r.schema, layouts: site.layouts, pages: site.pages, pageTypes: site.pageTypes, entries, collections, routes, seo: site.seo, bridgeVersion: 4, siteKitVersion: SITE_KIT_VERSION, order }
|
|
132
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ZoneDecl } from '../elements'
|
|
2
|
+
import type { BlockDef, CollectionDef, Decl, EntrySeed, Fields, LayoutDef, PageDef, PageTypeDef, Singles, SingleDef } from './types'
|
|
3
|
+
|
|
4
|
+
export const SLOT = 'SLOT' as const
|
|
5
|
+
export const humanise = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
|
|
6
|
+
|
|
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
|
+
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 } : {}) })
|
|
11
|
+
export const withTemplate = <C extends CollectionDef>(c: C, zone: Decl<unknown>, o: { layout?: string } = {}): C =>
|
|
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 })
|
|
15
|
+
export const layout = (o: { default?: boolean; parent?: string }, entries: Array<BlockDef | typeof SLOT>): LayoutDef => ({ kind: 'layout', ...o, entries })
|
|
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
|
+
export const pageType = (o: { label: string; route: string; layout?: string; blocks: Decl<unknown> }): PageTypeDef => ({ kind: 'pageType', ...o, blocks: o.blocks as ZoneDecl })
|
|
18
|
+
export const singlesOf = (s: Singles) => s
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { BooleanRead, ChoiceReadValue, DateRead, EntriesRead, EntryRead, FileRead, ImageRead, LinkRead, NumberRead, RichTextRead, TextRead, VideoRead } from '../elements'
|
|
2
|
+
import type { BlockDef, Decl, Fields, Read, SingleDef, Simplify, ZoneNode } from './types'
|
|
3
|
+
|
|
4
|
+
type Common = { label?: string; description?: string; required?: boolean; translatable?: boolean; lock?: string[]; renamedFrom?: string }
|
|
5
|
+
/** Options that are part of the field declaration itself go top-level; the rest is the element's `options`. */
|
|
6
|
+
const leaf = <R>(element: string, common: Common & { default?: unknown }, options: Record<string, unknown>): Decl<R> => {
|
|
7
|
+
const clean = Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined))
|
|
8
|
+
const decl: Record<string, unknown> = { element }
|
|
9
|
+
for (const k of ['label', 'description', 'required', 'translatable', 'default', 'lock', 'renamedFrom'] as const) if (common[k] !== undefined) decl[k] = common[k]
|
|
10
|
+
if (Object.keys(clean).length) decl.options = clean
|
|
11
|
+
return decl as Decl<R>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const text = (o: Common & { default?: string; multiline?: boolean; format?: 'plain' | 'email' | 'tel' | 'url' | 'code'; maxLength?: number; pattern?: string } = {}) =>
|
|
15
|
+
leaf<TextRead>('u90/text', o, { multiline: o.multiline, format: o.format, maxLength: o.maxLength, pattern: o.pattern })
|
|
16
|
+
export const richText = (o: Common & { default?: unknown; inline?: boolean; blocks?: string[]; headings?: string[]; marks?: string[] } = {}) =>
|
|
17
|
+
leaf<RichTextRead>('u90/rich-text', o, { inline: o.inline, blocks: o.blocks, headings: o.headings, marks: o.marks })
|
|
18
|
+
export const image = (o: Common & { default?: unknown; constraints?: unknown; variants?: Record<string, { media: string; constraints?: unknown }> } = {}) =>
|
|
19
|
+
leaf<ImageRead>('u90/image', o, { constraints: o.constraints, variants: o.variants })
|
|
20
|
+
export const video = (o: Common & { default?: unknown; poster?: unknown } = {}) => leaf<VideoRead>('u90/video', o, { poster: o.poster })
|
|
21
|
+
export const file = (o: Common & { default?: unknown; mimeTypes?: string[]; maxBytes?: number } = {}) => leaf<FileRead>('u90/file', o, { mimeTypes: o.mimeTypes, maxBytes: o.maxBytes })
|
|
22
|
+
export const link = (o: Common & { default?: unknown; allow?: Array<'page' | 'entry' | 'url' | 'email' | 'tel'> } = {}) => leaf<LinkRead>('u90/link', o, { allow: o.allow })
|
|
23
|
+
export const date = (o: Common & { default?: string; min?: string; max?: string } = {}) => leaf<DateRead>('u90/date', o, { min: o.min, max: o.max })
|
|
24
|
+
export const number = (o: Common & { default?: number; integer?: boolean; min?: number; max?: number; step?: number } = {}) => leaf<NumberRead>('u90/number', o, { integer: o.integer, min: o.min, max: o.max, step: o.step })
|
|
25
|
+
export const boolean = (o: Common & { default?: boolean } = {}) => leaf<BooleanRead>('u90/boolean', o, {})
|
|
26
|
+
export const choice = (values: Array<string | { value: string; label: string }>, o: Common & { default?: string | string[]; multiple?: boolean } = {}) =>
|
|
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 } = {}) =>
|
|
29
|
+
leaf<EntryRead | EntryRead[] | null>('u90/relation', o, {
|
|
30
|
+
to, inverseOf: o.inverseOf, depth: o.depth,
|
|
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
|
+
})
|
|
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 })
|
|
36
|
+
export const single = <R>(s: SingleDef<R>, o: Common = {}): Decl<R> => leaf<R>('u90/single', o, { key: s.key })
|
|
37
|
+
|
|
38
|
+
export const group = <F extends Fields>(fields: F, o: { label?: string } = {}): Decl<{ [K in keyof F]: Read<F[K]> }> & { readonly __group: true } =>
|
|
39
|
+
({ container: 'u90/group', ...(o.label ? { label: o.label } : {}), fields, order: Object.keys(fields) }) as never
|
|
40
|
+
export const list = <D extends Decl<unknown>>(
|
|
41
|
+
item: D,
|
|
42
|
+
o: { label?: string; min?: number; max?: number; default?: unknown[] } = {},
|
|
43
|
+
): Decl<Array<D extends { __group: true } ? Simplify<{ _id: string } & Read<D>> : Read<D>>> =>
|
|
44
|
+
({ container: 'u90/list', item, ...Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) }) as never
|
|
45
|
+
/**
|
|
46
|
+
* The registry's `allow`/`default` are block NAMES (`string[]`); `defineSite` needs the actual
|
|
47
|
+
* `BlockDef` objects to walk the site and collect every reachable block, so the zone builders
|
|
48
|
+
* stash them out of band on a non-enumerable `__blocks` property. Non-enumerable so `deepEqual`
|
|
49
|
+
* (own enumerable properties only) and `JSON.stringify` both ignore it — the wire format this
|
|
50
|
+
* produces is exactly the registry's `ZoneDecl`, nothing extra.
|
|
51
|
+
*/
|
|
52
|
+
const withBlocks = <T extends object>(decl: T, blocks: BlockDef[]): T => {
|
|
53
|
+
Object.defineProperty(decl, '__blocks', { value: blocks })
|
|
54
|
+
return decl
|
|
55
|
+
}
|
|
56
|
+
export const zone = (o: { label?: string; allow: BlockDef[]; default?: BlockDef[] }): Decl<ZoneNode> =>
|
|
57
|
+
withBlocks({ container: 'u90/zone', ...(o.label ? { label: o.label } : {}), allow: o.allow.map((b) => b.name), ...(o.default ? { default: o.default.map((b) => b.name) } : {}) }, o.allow) as never
|
|
58
|
+
export const fixed = (blocks: BlockDef[], o: { label?: string } = {}): Decl<ZoneNode> =>
|
|
59
|
+
withBlocks({ container: 'u90/zone', ...(o.label ? { label: o.label } : {}), allow: blocks.map((b) => b.name), default: blocks.map((b) => b.name), fixed: true }, blocks) as never
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The v1 authoring model: builders, documents, `defineSite` and `manifestFrom`. */
|
|
2
|
+
export * from './types'
|
|
3
|
+
export * from './fields'
|
|
4
|
+
export * from './documents'
|
|
5
|
+
export { defineSite, manifestFrom, SiteDeclarationError } from './defineSite'
|
|
6
|
+
export { SITE_KIT_VERSION } from './version'
|
|
7
|
+
export * from './wire'
|
|
8
|
+
export { assemble, type AssembleOptions, type RouteRead, type RouteSeo } from './assemble'
|
|
9
|
+
export { replyFrom, layoutChain, type ReplyTarget } from './replies'
|
|
10
|
+
export { applyTitleTemplate } from '../seo/title'
|
|
11
|
+
export { metadataFor, type PageSeoInput } from '../seo/metadata'
|
|
12
|
+
export { organisationJsonLd } from '../seo/jsonLd'
|
|
13
|
+
export { robotsFor } from '../seo/robots'
|
|
14
|
+
export { sitemapFor } from '../seo/sitemap'
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { applyQueryPicks, collectRefs, entryKey, inverseKey, path, queryKey, type DocumentDecl, type EntryRecord, type Id, type MediaRecord, type PageRecord, type QueryStored, type Ref } from '../elements'
|
|
2
|
+
import type { Store, StoredPage } from './store'
|
|
3
|
+
import type { Site } from './types'
|
|
4
|
+
import type { DocumentReply, Reply, ReplyDocument, StoredSingle } from './wire'
|
|
5
|
+
|
|
6
|
+
/** What a read asks for: a route, a code page by key, or an entry by collection and slug. */
|
|
7
|
+
export type ReplyTarget = { route: string } | { page: string } | { entry: { collection: string; slug: string } }
|
|
8
|
+
|
|
9
|
+
const routeToRegex = (route: string) => new RegExp('^' + route.replace(/\/:([a-zA-Z]+)/g, '/(?<$1>[^/]+)') + '$')
|
|
10
|
+
/** A layout and its parents, outermost first, from the running code's declarations. */
|
|
11
|
+
export const layoutChain = (site: Site, key: string): string[] => {
|
|
12
|
+
const chain: string[] = []
|
|
13
|
+
let k: string | undefined = key
|
|
14
|
+
while (k) {
|
|
15
|
+
if (!site.layouts[k]) throw new Error(`Layout "${k}" is not declared.`)
|
|
16
|
+
chain.unshift(k)
|
|
17
|
+
k = site.layouts[k].parent
|
|
18
|
+
}
|
|
19
|
+
return chain
|
|
20
|
+
}
|
|
21
|
+
const tagOf = (r: Ref): string =>
|
|
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
|
+
|
|
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 } {
|
|
26
|
+
let rows = [...store.entries.values()].filter((e) => e.collection === collection)
|
|
27
|
+
for (const w of q.where ?? [])
|
|
28
|
+
rows = rows.filter((e) => {
|
|
29
|
+
const v = e.fields[w.field]
|
|
30
|
+
return w.op === 'eq' ? v === w.value : w.op === 'neq' ? v !== w.value : w.op === 'in' ? (w.value as unknown[]).includes(v) : w.op === 'not-in' ? !(w.value as unknown[]).includes(v) : v != null
|
|
31
|
+
})
|
|
32
|
+
const sort = q.sort
|
|
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
|
+
return applyQueryPicks(rows.map((r) => r.id), q, (id) => store.entries.has(entryKey(collection, id)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Found = { doc: ReplyDocument; decl: DocumentDecl }
|
|
38
|
+
const pageFound = (site: Site, p: StoredPage): Found | null => {
|
|
39
|
+
const blocks = p.key ? site.pages[p.key]?.blocks : p.pageType ? site.pageTypes[p.pageType]?.blocks : undefined
|
|
40
|
+
if (!blocks) return null
|
|
41
|
+
return {
|
|
42
|
+
decl: { kind: 'page', blocks },
|
|
43
|
+
doc: { kind: 'page', id: p.id, slug: p.slug, route: p.route, ...(p.key ? { key: p.key } : {}), ...(p.pageType ? { pageType: p.pageType } : {}), ...(p.title ? { title: p.title } : {}), seo: p.seo ?? null, layoutChain: layoutChain(site, p.layout), stored: p.doc },
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const entryFound = (site: Site, store: Store, collection: string, slug: string): Found | null => {
|
|
47
|
+
const e = [...store.entries.values()].find((x) => x.collection === collection && x.slug === slug)
|
|
48
|
+
if (!e) return null
|
|
49
|
+
const c = Object.values(site.collections).find((x) => x.name === collection)
|
|
50
|
+
return {
|
|
51
|
+
decl: { kind: 'entry', collection },
|
|
52
|
+
doc: { kind: 'entry', id: String(e.id), slug: e.slug, collection, ...(e.route ? { route: e.route } : {}), seo: e.seo ?? null, layoutChain: layoutChain(site, c?.template?.layout ?? site.defaultLayout), stored: { fields: e.fields, blocks: e.blocks } },
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The reply a platform gives for `target`, built from a store: the document and its layouts as
|
|
58
|
+
* stored, every media record, page, entry and Single the store holds, the queries and inverse
|
|
59
|
+
* lookups they ask for, and the tags the read depends on. The fake answers with it, and a site
|
|
60
|
+
* reading its own defaults goes through it too, so every read is assembled the same way.
|
|
61
|
+
*/
|
|
62
|
+
export function replyFrom(site: Site, store: Store, target: ReplyTarget, manifest: string): Reply {
|
|
63
|
+
let found: Found | null = null
|
|
64
|
+
if ('route' in target) {
|
|
65
|
+
const p = [...store.pages.values()].find((x) => x.route === target.route)
|
|
66
|
+
if (p) found = pageFound(site, p)
|
|
67
|
+
else
|
|
68
|
+
for (const c of Object.values(site.collections)) {
|
|
69
|
+
const slug = c.route ? routeToRegex(c.route).exec(target.route)?.groups?.slug : undefined
|
|
70
|
+
if (slug) { found = entryFound(site, store, c.name, slug); break }
|
|
71
|
+
}
|
|
72
|
+
} else if ('page' in target) {
|
|
73
|
+
const p = store.pages.get(target.page)
|
|
74
|
+
found = p ? pageFound(site, p) : null
|
|
75
|
+
} else found = entryFound(site, store, target.entry.collection, target.entry.slug)
|
|
76
|
+
if (!found) return { status: 'missing', manifest, tags: ['site'] }
|
|
77
|
+
|
|
78
|
+
const { doc, decl } = found
|
|
79
|
+
const layouts: Record<string, unknown> = {}
|
|
80
|
+
const tags = new Set<string>([`${doc.kind}:${doc.id}`])
|
|
81
|
+
const refs: Ref[] = collectRefs(site.schema, decl, doc.stored, path.doc(doc.kind, doc.id))
|
|
82
|
+
const layoutDecl: DocumentDecl = { kind: 'layout', blocks: { container: 'u90/zone', allow: Object.keys(site.schema.blocks) } }
|
|
83
|
+
for (const key of doc.layoutChain) {
|
|
84
|
+
const l = store.layouts.get(key)
|
|
85
|
+
if (!l) continue
|
|
86
|
+
layouts[key] = l.doc
|
|
87
|
+
tags.add(`layout:${key}`)
|
|
88
|
+
refs.push(...collectRefs(site.schema, layoutDecl, l.doc, `layout:${key}`))
|
|
89
|
+
}
|
|
90
|
+
for (const r of refs) tags.add(tagOf(r))
|
|
91
|
+
tags.add('site')
|
|
92
|
+
|
|
93
|
+
// The queries and inverse lookups the document, its layouts and every stored entry ask for.
|
|
94
|
+
const lookups: Ref[] = [...refs]
|
|
95
|
+
for (const e of store.entries.values()) lookups.push(...collectRefs(site.schema, { kind: 'entry', collection: e.collection }, { fields: e.fields, blocks: e.blocks }, path.doc('entry', e.id)))
|
|
96
|
+
const queries: DocumentReply['queries'] = {}
|
|
97
|
+
const inverses: DocumentReply['inverses'] = {}
|
|
98
|
+
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 === 'inverse')
|
|
101
|
+
inverses[inverseKey(r.collection, r.field, r.id)] ??= [...store.entries.values()]
|
|
102
|
+
.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)))
|
|
103
|
+
.map((e) => e.id)
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
status: 'ok',
|
|
107
|
+
manifest,
|
|
108
|
+
document: doc,
|
|
109
|
+
layouts,
|
|
110
|
+
singles: Object.fromEntries([...store.singles].map(([k, s]) => [k, { stored: s.doc } satisfies StoredSingle])),
|
|
111
|
+
media: Object.fromEntries(store.media) as Record<string, MediaRecord>,
|
|
112
|
+
pages: Object.fromEntries([...store.pages.values()].map((p) => [String(p.id), { id: p.id, route: p.route, title: p.title } satisfies PageRecord])),
|
|
113
|
+
entries: Object.fromEntries([...store.entries].map(([k, e]) => [k, { id: e.id, slug: e.slug, ...(e.route ? { route: e.route } : {}), fields: e.fields, ...(e.blocks ? { blocks: e.blocks } : {}) } satisfies EntryRecord])),
|
|
114
|
+
queries,
|
|
115
|
+
inverses,
|
|
116
|
+
tags: [...tags],
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { entryKey, seed, newId, type DocumentDecl, type EntryRecord, type MediaRecord, type SeedCtx } from '../elements'
|
|
2
|
+
import type { Site } from './types'
|
|
3
|
+
import type { ReplySeo } from './wire'
|
|
4
|
+
|
|
5
|
+
export type StoredPage = { id: string; slug: string; route: string; layout: string; key?: string; pageType?: string; title: string; doc: any; seo?: ReplySeo }
|
|
6
|
+
export type Store = {
|
|
7
|
+
pages: Map<string, StoredPage> // by key (code pages) or by id (platform pages)
|
|
8
|
+
layouts: Map<string, { id: string; parent?: string; doc: any }>
|
|
9
|
+
singles: Map<string, { doc: { value: unknown } }>
|
|
10
|
+
entries: Map<string, EntryRecord & { collection: string; seo?: ReplySeo }> // key `${collection}:${id}`
|
|
11
|
+
media: Map<string, MediaRecord>
|
|
12
|
+
}
|
|
13
|
+
/** `home` → `Home`, `caseStudies` → `Case studies`: a label for a declared page's key, exactly as the platform humanises a page's own key (`src/content/schema.ts`'s `humanise`), so the fake and the platform agree. */
|
|
14
|
+
const humanise = (key: string): string => {
|
|
15
|
+
const words = key.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ').trim().toLowerCase()
|
|
16
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
17
|
+
}
|
|
18
|
+
/** Keys are ids; a `{ source }` asset becomes media `seed:<source>` served from the source path. */
|
|
19
|
+
export const seedCtx = (media: Map<string, MediaRecord>): SeedCtx => ({
|
|
20
|
+
importAsset: async (source) => { const id = `seed:${source}`; media.set(id, { id, url: source, mimeType: '', width: null, height: null, filename: source.split('/').pop() ?? source }); return id },
|
|
21
|
+
resolvePage: (key) => key,
|
|
22
|
+
resolveEntry: (collection, key) => `${key}`,
|
|
23
|
+
newId,
|
|
24
|
+
})
|
|
25
|
+
/** Seeds one entry into the store — a declared seed entry (`seedSite`) or the fake's `addEntry`. Its id is its slug. */
|
|
26
|
+
export async function seedEntry(site: Site, store: Store, ctx: SeedCtx, collection: string, slug: string, fields?: Record<string, unknown>, blocks?: unknown[]): Promise<void> {
|
|
27
|
+
const c = site.collections[collection]
|
|
28
|
+
const doc = (await seed(site.schema, { kind: 'entry', collection }, { fields, blocks }, ctx, `entry:${slug}`)) as { fields: Record<string, unknown>; blocks?: unknown[] }
|
|
29
|
+
store.entries.set(entryKey(collection, slug), { id: slug, collection, slug, route: c?.route?.replace(':slug', slug), fields: doc.fields, blocks: doc.blocks })
|
|
30
|
+
}
|
|
31
|
+
export async function seedSite(site: Site): Promise<Store> {
|
|
32
|
+
const store: Store = { pages: new Map(), layouts: new Map(), singles: new Map(), entries: new Map(), media: new Map() }
|
|
33
|
+
const ctx = seedCtx(store.media)
|
|
34
|
+
for (const [key, l] of Object.entries(site.layouts)) {
|
|
35
|
+
const decl: DocumentDecl = { kind: 'layout', blocks: { container: 'u90/zone', allow: l.blocks.filter((b) => b !== 'SLOT'), default: l.blocks } }
|
|
36
|
+
store.layouts.set(key, { id: key, parent: l.parent, doc: await seed(site.schema, decl, undefined, ctx, `layout:${key}`) })
|
|
37
|
+
}
|
|
38
|
+
for (const [key, s] of Object.entries(site.schema.singles)) store.singles.set(key, { doc: await seed(site.schema, { kind: 'single', key }, undefined, ctx, `single:${key}`) as any })
|
|
39
|
+
for (const [key, p] of Object.entries(site.pages)) {
|
|
40
|
+
const doc = await seed(site.schema, { kind: 'page', blocks: p.blocks }, undefined, ctx, `page:${key}`)
|
|
41
|
+
store.pages.set(key, { id: key, key, slug: p.route === '/' ? '' : p.route.replace(/^\//, ''), route: p.route, layout: p.layout, title: humanise(key), doc })
|
|
42
|
+
}
|
|
43
|
+
for (const c of Object.values(site.collections)) for (const e of c.entries ?? []) await seedEntry(site, store, ctx, c.name, e.slug, e.fields, e.blocks)
|
|
44
|
+
return store
|
|
45
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { KnowledgePolicyV1 } from '../features/contracts/knowledgePolicy'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import type { BlockDecl, CollectionDecl, FieldDecl, GroupDecl, LeafDecl, ListDecl, Schema, SingleDecl, ZoneDecl } from '../elements'
|
|
4
|
+
|
|
5
|
+
/** A wire-format declaration carrying its read type at the type level only. */
|
|
6
|
+
export type Decl<R> = FieldDecl & { readonly __read?: R }
|
|
7
|
+
export type Read<D> = D extends Decl<infer R> ? R : never
|
|
8
|
+
export type Fields = Record<string, Decl<unknown>>
|
|
9
|
+
/**
|
|
10
|
+
* Flattens an intersection into a single mapped object type. The strict `Equal` check used by
|
|
11
|
+
* `types.test-d.ts` treats an intersection (`{ _id: string } & Read<D>`) as distinct from the
|
|
12
|
+
* equivalent plain object literal type, so `list()`'s group-item type and `BlockProps`'s result
|
|
13
|
+
* are run through this before being handed back.
|
|
14
|
+
*/
|
|
15
|
+
export type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
|
16
|
+
/**
|
|
17
|
+
* A zone prop arrives rendered. The import is type-only, so `verbatimModuleSyntax` erases it
|
|
18
|
+
* entirely at build time — `site/` still imports no React at runtime — and `ReactNode` is the
|
|
19
|
+
* real read type here rather than `unknown`, narrowed no further by `server/render`.
|
|
20
|
+
*/
|
|
21
|
+
export type ZoneNode = ReactNode
|
|
22
|
+
|
|
23
|
+
export type SingleDef<R = unknown> = { key: string; decl: Decl<R> }
|
|
24
|
+
export type Singles = Record<string, SingleDef>
|
|
25
|
+
/**
|
|
26
|
+
* An entry a collection starts with: its slug (also the key a seed's `{ $ref: { key } }` names it
|
|
27
|
+
* by) and seed values for its fields and template blocks. Seeded like any default — the site
|
|
28
|
+
* renders it without a platform, and the manifest carries it for the platform to seed on pairing.
|
|
29
|
+
*/
|
|
30
|
+
export type EntrySeed = { slug: string; fields?: Record<string, unknown>; blocks?: unknown[] }
|
|
31
|
+
export type CollectionDef<F extends Fields = Fields> = {
|
|
32
|
+
kind: 'collection'; name: string; label: string; fields: F
|
|
33
|
+
route?: string; defaultSort?: CollectionDecl['defaultSort']
|
|
34
|
+
template?: { zone: ZoneDecl; layout?: string }
|
|
35
|
+
entries?: EntrySeed[]
|
|
36
|
+
}
|
|
37
|
+
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
|
+
}
|
|
40
|
+
export type LayoutDef = { kind: 'layout'; default?: boolean; parent?: string; entries: Array<BlockDef | 'SLOT'> }
|
|
41
|
+
export type PageDef = { kind: 'page'; route: string; layout?: string; blocks: ZoneDecl }
|
|
42
|
+
export type PageTypeDef = { kind: 'pageType'; label: string; route: string; layout?: string; blocks: ZoneDecl }
|
|
43
|
+
export type Seo = { titleTemplate?: string; defaultImage?: string; organisation?: { name: string; url?: string; logo?: string } }
|
|
44
|
+
|
|
45
|
+
export type Site = {
|
|
46
|
+
knowledge?: KnowledgePolicyV1
|
|
47
|
+
singles: Singles; collections: Record<string, CollectionDef>
|
|
48
|
+
layouts: Record<string, { default: boolean; parent?: string; blocks: string[] }>
|
|
49
|
+
pages: Record<string, { route: string; layout: string; blocks: ZoneDecl }>
|
|
50
|
+
pageTypes: Record<string, { label: string; route: string; layout: string; blocks: ZoneDecl }>
|
|
51
|
+
blocks: Record<string, BlockDef>; schema: Schema; seo: Seo; defaultLayout: string
|
|
52
|
+
}
|
|
53
|
+
export type SiteManifest = {
|
|
54
|
+
knowledge?: KnowledgePolicyV1
|
|
55
|
+
schema: Schema
|
|
56
|
+
layouts: Site['layouts']; pages: Site['pages']; pageTypes: Site['pageTypes']
|
|
57
|
+
/** Each collection's declared seed entries, by collection name; collections that declare none are absent. */
|
|
58
|
+
entries: Record<string, EntrySeed[]>
|
|
59
|
+
/** Each collection's entry route pattern and template layout, by collection name, where the code declares them. */
|
|
60
|
+
collections: Record<string, { route?: string; layout?: string }>
|
|
61
|
+
routes: string[]; seo: Seo; bridgeVersion: 4; siteKitVersion: string
|
|
62
|
+
/** Each record's declared order, since jsonb storage loses key order. */
|
|
63
|
+
order?: { blocks: string[]; collections: string[]; singles: string[]; pages: string[]; pageTypes: string[]; layouts: string[] }
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The `entry` a block declared with `entry:` receives, exactly as `server/render.tsx` builds it: the
|
|
67
|
+
* entry page's own id, slug and route (from the collection's pattern), and its fields typed from the
|
|
68
|
+
* collection's declaration. Not `EntryRead`: an entry page's entry has no `collection` or `status`
|
|
69
|
+
* (finding 21).
|
|
70
|
+
*/
|
|
71
|
+
export type EntryReadOf<C> = C extends CollectionDef<infer F> ? { id: string; slug: string; route?: string; fields: { [K in keyof F]: Read<F[K]> } } : never
|
|
72
|
+
export type BlockProps<B> = B extends BlockDef<infer F, infer C>
|
|
73
|
+
? Simplify<{ [K in keyof F]: Read<F[K]> } & (C extends CollectionDef ? { entry: EntryReadOf<C> } : {})>
|
|
74
|
+
: never
|
|
75
|
+
export type { FieldDecl, LeafDecl, GroupDecl, ListDecl, ZoneDecl, BlockDecl, CollectionDecl, SingleDecl, Schema }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SITE_KIT_VERSION = '1.6.2'
|