@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.
Files changed (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. 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'