@gradial/aci 0.1.20-preview.2 → 0.1.20

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 (249) hide show
  1. package/README.md +48 -110
  2. package/bin/{aci → aci-darwin-arm64} +0 -0
  3. package/bin/aci.js +99 -6
  4. package/dist/assets/index.d.ts +3 -3
  5. package/dist/assets/index.js +3 -3
  6. package/dist/astro/index.d.ts +14 -3
  7. package/dist/astro/index.js +24 -5
  8. package/dist/content/assets.d.ts +17 -0
  9. package/dist/content/assets.js +65 -0
  10. package/dist/content/index.d.ts +2 -0
  11. package/dist/content/index.js +2 -0
  12. package/dist/content/provider.d.ts +19 -0
  13. package/dist/content/provider.js +7 -0
  14. package/dist/content/resolve-slots.d.ts +9 -0
  15. package/dist/content/resolve-slots.js +24 -0
  16. package/dist/content/types.d.ts +1 -1
  17. package/dist/define-component.d.ts +1 -2
  18. package/dist/define-component.js +73 -11
  19. package/dist/define-layout.d.ts +2 -0
  20. package/dist/define-layout.js +3 -0
  21. package/dist/dev/content-watch.d.ts +37 -0
  22. package/dist/dev/content-watch.js +154 -0
  23. package/dist/dev/index.d.ts +24 -43
  24. package/dist/dev/index.js +4 -169
  25. package/dist/index.d.ts +14 -5
  26. package/dist/index.js +8 -8
  27. package/dist/next/asset-route.d.ts +2 -0
  28. package/dist/next/asset-route.js +12 -4
  29. package/dist/next/config.d.ts +2 -2
  30. package/dist/next/config.js +6 -6
  31. package/dist/next/content-watch.js +6 -1
  32. package/dist/next/index.d.ts +2 -2
  33. package/dist/next/index.js +2 -2
  34. package/dist/next/middleware.d.ts +7 -3
  35. package/dist/next/middleware.js +12 -7
  36. package/dist/next/page.d.ts +16 -15
  37. package/dist/next/page.js +30 -10
  38. package/dist/next/render.d.ts +5 -0
  39. package/dist/next/render.js +17 -0
  40. package/dist/next/root-layout.d.ts +4 -0
  41. package/dist/next/root-layout.js +5 -0
  42. package/dist/next/server.d.ts +14 -13
  43. package/dist/next/server.js +13 -3
  44. package/dist/providers/file.d.ts +2 -0
  45. package/dist/providers/file.js +61 -1
  46. package/dist/providers/s3.d.ts +1 -0
  47. package/dist/providers/s3.js +14 -1
  48. package/dist/react/Media.d.ts +24 -0
  49. package/dist/react/Media.js +30 -0
  50. package/dist/react/Picture.d.ts +12 -0
  51. package/dist/react/Picture.js +11 -0
  52. package/dist/react/VideoPlayer.d.ts +13 -0
  53. package/dist/react/{GradialVideoPlayer.js → VideoPlayer.js} +6 -5
  54. package/dist/react/index.d.ts +333 -3
  55. package/dist/react/index.js +89 -3
  56. package/dist/registry.d.ts +15 -0
  57. package/dist/registry.js +10 -0
  58. package/dist/testing/index.d.ts +3 -0
  59. package/dist/testing/index.js +15 -2
  60. package/dist/types/block-ref.d.ts +26 -0
  61. package/dist/types/block-ref.js +28 -0
  62. package/dist/types/component.d.ts +36 -41
  63. package/dist/types/config.d.ts +1 -2
  64. package/dist/types/image.d.ts +33 -20
  65. package/dist/types/image.js +42 -50
  66. package/dist/types/index.d.ts +1 -0
  67. package/dist/types/index.js +1 -0
  68. package/dist/types/link.d.ts +38 -0
  69. package/dist/types/link.js +58 -0
  70. package/dist/types/media.d.ts +16 -35
  71. package/dist/types/media.js +15 -84
  72. package/dist/types/page.d.ts +1 -2
  73. package/dist/types/render-mode.d.ts +0 -1
  74. package/dist/types/renderer.d.ts +1 -1
  75. package/dist/types/rich-text.d.ts +40 -0
  76. package/dist/types/rich-text.js +39 -0
  77. package/dist/types/video.d.ts +7 -13
  78. package/dist/types/video.js +3 -3
  79. package/package.json +32 -74
  80. package/src/cli/compile-registry.mjs +236 -59
  81. package/src/cli/validate-content.mjs +38 -75
  82. package/src/types/image.ts +79 -60
  83. package/templates/astro/template/.aci.yaml +27 -0
  84. package/templates/astro/template/.storybook/main.ts +79 -0
  85. package/templates/astro/template/.storybook/manager.ts +6 -0
  86. package/templates/astro/template/.storybook/preview.ts +33 -0
  87. package/templates/astro/template/astro.config.mjs +37 -0
  88. package/templates/astro/template/package.json.tmpl +41 -0
  89. package/templates/astro/template/src/cms/contracts/layouts/index.ts +18 -0
  90. package/templates/astro/template/src/cms/registry.ts +28 -0
  91. package/templates/astro/template/src/components/chrome/Footer.astro +107 -0
  92. package/templates/astro/template/src/components/chrome/Header.astro +94 -0
  93. package/templates/astro/template/src/components/elements/AtomicBlock.astro +15 -0
  94. package/templates/astro/template/src/components/elements/AtomicBlocks.astro +16 -0
  95. package/templates/astro/template/src/components/elements/Button.astro +51 -0
  96. package/templates/astro/template/src/components/elements/ButtonBlock.astro +25 -0
  97. package/templates/astro/template/src/components/elements/Container.astro +11 -0
  98. package/templates/astro/template/src/components/elements/Icon.astro +31 -0
  99. package/templates/astro/template/src/components/elements/Logo.astro +29 -0
  100. package/templates/astro/template/src/components/elements/RemoteImage.astro +41 -0
  101. package/templates/astro/template/src/components/sections/ContainerSection.astro +32 -0
  102. package/templates/astro/template/src/components/sections/FeatureCard.astro +23 -0
  103. package/templates/astro/template/src/components/sections/FeatureGrid.astro +35 -0
  104. package/templates/astro/template/src/components/sections/HomeHero.astro +51 -0
  105. package/templates/astro/template/src/components/sections/StatsBar.astro +33 -0
  106. package/templates/astro/template/src/design-system/styles.css +399 -0
  107. package/templates/astro/template/src/design-system/tokens/colors.css +60 -0
  108. package/templates/astro/template/src/design-system/tokens/components.css +69 -0
  109. package/templates/astro/template/src/design-system/tokens/index.css +6 -0
  110. package/templates/astro/template/src/design-system/tokens/radii.css +5 -0
  111. package/templates/astro/template/src/design-system/tokens/shadows.css +5 -0
  112. package/templates/astro/template/src/design-system/tokens/spacing.css +13 -0
  113. package/templates/astro/template/src/design-system/tokens/typography.css +24 -0
  114. package/templates/astro/template/src/env.d.ts +1 -0
  115. package/templates/astro/template/src/fixtures/atomicBlocks.ts +33 -0
  116. package/templates/astro/template/src/fixtures/composition.ts +31 -0
  117. package/templates/astro/template/src/fixtures/index.ts +17 -0
  118. package/templates/astro/template/src/fixtures/sections.ts +39 -0
  119. package/templates/astro/template/src/layouts/SiteLayout.astro +30 -0
  120. package/templates/astro/template/src/pages/[...slug].astro +61 -0
  121. package/templates/astro/template/src/render/RenderPage.astro +66 -0
  122. package/templates/astro/template/src/stories/CompositionStoryFrame.astro +15 -0
  123. package/templates/astro/template/src/stories/HomeHero.stories.ts +24 -0
  124. package/templates/astro/template/src/stories/SectionStoryFrame.astro +16 -0
  125. package/templates/astro/template/src/stories/StoryDocFrame.astro +31 -0
  126. package/templates/astro/template/tsconfig.json +11 -0
  127. package/templates/common/.agents/AGENTS.md +37 -0
  128. package/templates/common/.agents/skills/aci/SKILL.md +162 -0
  129. package/templates/common/.agents/skills/authoring/SKILL.md +590 -0
  130. package/templates/common/.agents/skills/authoring/references/block-registry.md +151 -0
  131. package/templates/common/.agents/skills/authoring/references/contracts.md +153 -0
  132. package/templates/common/.agents/skills/authoring/references/design-system.md +171 -0
  133. package/templates/common/.agents/skills/authoring/references/image-assets.md +73 -0
  134. package/templates/common/.agents/skills/authoring/references/repo-structure.md +157 -0
  135. package/templates/common/.agents/skills/migration/SKILL.md +160 -0
  136. package/templates/common/.agents/skills/migration/references/analyzing-source.md +215 -0
  137. package/templates/common/.agents/skills/migration/references/component-migration.md +200 -0
  138. package/templates/common/.agents/skills/migration/references/selector-map.md +31 -0
  139. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  140. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  141. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  142. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  143. package/templates/common/.content/config/site.json +192 -0
  144. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +9 -0
  145. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +14 -0
  146. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +23 -0
  147. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +37 -0
  148. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +9 -0
  149. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +14 -0
  150. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +23 -0
  151. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +37 -0
  152. package/templates/common/.content/fragments/navbar/_index.json +25 -0
  153. package/templates/common/.content/fragments/site-footer/_index.json +62 -0
  154. package/templates/common/.content/pages/about/_index.json +51 -0
  155. package/templates/common/.content/pages/home/_index.json +112 -0
  156. package/templates/common/.content/schemas/site.schema.json +123 -0
  157. package/templates/common/README.md.tmpl +45 -0
  158. package/templates/common/contracts/components/button.contract.ts +19 -0
  159. package/templates/common/contracts/components/container.contract.ts +19 -0
  160. package/templates/common/contracts/components/featureCard.contract.ts +15 -0
  161. package/templates/common/contracts/components/featureGrid.contract.ts +16 -0
  162. package/templates/common/contracts/components/homeHero.contract.ts +36 -0
  163. package/templates/common/contracts/components/index.ts +21 -0
  164. package/templates/common/contracts/components/siteFooter.contract.ts +45 -0
  165. package/templates/common/contracts/components/siteNavigation.contract.ts +30 -0
  166. package/templates/common/contracts/components/statsBar.contract.ts +19 -0
  167. package/templates/common/design-system/styles.css +346 -0
  168. package/templates/common/design-system/tokens/colors.css +110 -0
  169. package/templates/common/design-system/tokens/components.css +86 -0
  170. package/templates/common/design-system/tokens/index.css +6 -0
  171. package/templates/common/design-system/tokens/radii.css +5 -0
  172. package/templates/common/design-system/tokens/shadows.css +9 -0
  173. package/templates/common/design-system/tokens/spacing.css +26 -0
  174. package/templates/common/design-system/tokens/typography.css +32 -0
  175. package/templates/common/eslint-rules/no-raw-tailwind-values.js +127 -0
  176. package/templates/common/public/assets/Dwire-model.png +0 -0
  177. package/templates/common/public/assets/bare-metal-logo.png +0 -0
  178. package/templates/common/public/assets/meridian-logo.svg +8 -0
  179. package/templates/common/public/assets/seattle.avif +0 -0
  180. package/templates/nextjs/template/.aci.yaml +25 -0
  181. package/templates/nextjs/template/.storybook/main.ts +13 -0
  182. package/templates/nextjs/template/.storybook/preview.ts +34 -0
  183. package/templates/nextjs/template/eslint.config.mjs +36 -0
  184. package/templates/nextjs/template/next-env.d.ts +6 -0
  185. package/templates/nextjs/template/next.config.ts +8 -0
  186. package/templates/nextjs/template/package.json.tmpl +46 -0
  187. package/templates/nextjs/template/postcss.config.mjs +7 -0
  188. package/templates/nextjs/template/src/app/[[...slug]]/page.tsx +8 -0
  189. package/templates/nextjs/template/src/app/gradial/assets/[releaseId]/[...path]/route.ts +31 -0
  190. package/templates/nextjs/template/src/app/layout.tsx +29 -0
  191. package/templates/nextjs/template/src/cms/contracts/components/button.contract.ts +19 -0
  192. package/templates/nextjs/template/src/cms/contracts/components/container.contract.ts +20 -0
  193. package/templates/nextjs/template/src/cms/contracts/components/homeHero.contract.ts +36 -0
  194. package/templates/nextjs/template/src/cms/contracts/components/index.ts +15 -0
  195. package/templates/nextjs/template/src/cms/contracts/components/siteFooter.contract.ts +45 -0
  196. package/templates/nextjs/template/src/cms/contracts/components/siteNavigation.contract.ts +30 -0
  197. package/templates/nextjs/template/src/cms/contracts/layouts/index.ts +16 -0
  198. package/templates/nextjs/template/src/cms/registry.ts +28 -0
  199. package/templates/nextjs/template/src/components/chrome/Footer.tsx +86 -0
  200. package/templates/nextjs/template/src/components/chrome/Header.tsx +74 -0
  201. package/templates/nextjs/template/src/components/elements/AtomicBlock.tsx +13 -0
  202. package/templates/nextjs/template/src/components/elements/Button.tsx +40 -0
  203. package/templates/nextjs/template/src/components/elements/ButtonBlock.tsx +18 -0
  204. package/templates/nextjs/template/src/components/elements/Container.tsx +14 -0
  205. package/templates/nextjs/template/src/components/elements/Icon.tsx +29 -0
  206. package/templates/nextjs/template/src/components/elements/Logo.tsx +38 -0
  207. package/templates/nextjs/template/src/components/sections/ContainerSection.tsx +28 -0
  208. package/templates/nextjs/template/src/components/sections/FeatureCard.tsx +25 -0
  209. package/templates/nextjs/template/src/components/sections/FeatureGrid.tsx +36 -0
  210. package/templates/nextjs/template/src/components/sections/HomeHero.tsx +62 -0
  211. package/templates/nextjs/template/src/components/sections/StatsBar.tsx +30 -0
  212. package/templates/nextjs/template/src/middleware.ts +4 -0
  213. package/templates/nextjs/template/src/stories/Button.stories.tsx +38 -0
  214. package/templates/nextjs/template/src/stories/Header.stories.tsx +30 -0
  215. package/templates/nextjs/template/src/stories/HomeHero.stories.tsx +33 -0
  216. package/templates/nextjs/template/tsconfig.json +23 -0
  217. package/templates/nextjs/template/vercel.json +6 -0
  218. package/dist/content/cache.test.d.ts +0 -1
  219. package/dist/content/cache.test.js +0 -86
  220. package/dist/content/contract.d.ts +0 -83
  221. package/dist/content/contract.js +0 -104
  222. package/dist/content/tailwind-validator.d.ts +0 -6
  223. package/dist/content/tailwind-validator.js +0 -31
  224. package/dist/content/validation.d.ts +0 -108
  225. package/dist/content/validation.js +0 -184
  226. package/dist/next/preview-banner.d.ts +0 -4
  227. package/dist/next/preview-banner.js +0 -51
  228. package/dist/next/preview.d.ts +0 -7
  229. package/dist/next/preview.js +0 -37
  230. package/dist/react/GradialMedia.d.ts +0 -24
  231. package/dist/react/GradialMedia.js +0 -31
  232. package/dist/react/GradialPicture.d.ts +0 -14
  233. package/dist/react/GradialPicture.js +0 -30
  234. package/dist/react/GradialVideoPlayer.d.ts +0 -13
  235. package/dist/sveltekit/index.d.ts +0 -23
  236. package/dist/sveltekit/index.js +0 -62
  237. package/dist/sveltekit/preview.d.ts +0 -5
  238. package/dist/sveltekit/preview.js +0 -33
  239. package/src/cli/verify-renderer.mjs +0 -73
  240. package/src/types/component.ts +0 -59
  241. package/src/types/config.ts +0 -38
  242. package/src/types/data.ts +0 -47
  243. package/src/types/index.ts +0 -10
  244. package/src/types/layout.ts +0 -29
  245. package/src/types/media.ts +0 -125
  246. package/src/types/page.ts +0 -48
  247. package/src/types/render-mode.ts +0 -18
  248. package/src/types/renderer.ts +0 -83
  249. package/src/types/video.ts +0 -66
@@ -3,7 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { z } from 'zod';
6
- import { GradialImageSchema } from '../types/image.ts';
6
+ import { ImageSchema } from '../types/image.ts';
7
+ import { getBlockRefMeta } from '../types/block-ref.ts';
7
8
 
8
9
  const DATA_TYPE_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
9
10
 
@@ -47,30 +48,20 @@ async function compileRegistry(options) {
47
48
  const schemaFiles = [];
48
49
  for (const [index, component] of components.entries()) {
49
50
  validateComponent(component, index, seenComponents);
50
- const schema = compileSchema(component.schema, component.name);
51
- const schemaFile = `schemas/${component.name}.schema.json`;
51
+ const componentId = component.id;
52
+ const schema = compileSchema(component.props, componentId);
53
+ const schemaFile = `schemas/${componentId}.schema.json`;
52
54
  const schemaPath = path.join(outDir, schemaFile);
53
55
  await writeFile(schemaPath, JSON.stringify(schema, null, 2) + '\n');
54
56
  schemaFiles.push(schemaPath);
55
- const renderModes = component.renderModes ?? component.render;
56
- const varyDimensions = component.varyDimensions ?? component.vary;
57
57
  const imageSlots = normalizeImageSlots(component.imageSlots);
58
+ const slots = extractBlockSlots(component.props, componentId);
58
59
  componentEntries.push({
59
- name: component.name,
60
- schemaFile,
61
- render: {
62
- static: Boolean(renderModes.canStatic ?? renderModes.static),
63
- ssr: Boolean(renderModes.canSSR ?? renderModes.ssr),
64
- clientIsland: Boolean(renderModes.canClientIsland ?? renderModes.clientIsland),
65
- },
66
- renderModes: {
67
- canStatic: Boolean(renderModes.canStatic ?? renderModes.static),
68
- canSSR: Boolean(renderModes.canSSR ?? renderModes.ssr),
69
- canClientIsland: Boolean(renderModes.canClientIsland ?? renderModes.clientIsland),
70
- },
71
- ...(component.defaultIslandMode ? { defaultIslandMode: component.defaultIslandMode } : {}),
72
- ...(varyDimensions?.length ? { vary: varyDimensions, varyDimensions } : {}),
60
+ id: componentId,
61
+ name: componentId,
62
+ props: schemaFile,
73
63
  ...(Object.keys(imageSlots).length ? { imageSlots } : {}),
64
+ ...(Object.keys(slots).length ? { slots } : {}),
74
65
  });
75
66
  }
76
67
 
@@ -95,16 +86,14 @@ async function compileRegistry(options) {
95
86
  });
96
87
  }
97
88
 
98
- // Post-compilation: detect nested block patterns in JSON schemas and
99
- // validate that referenced component names exist in the registry.
100
- const knownNames = new Set(componentEntries.map((c) => c.name));
89
+ const knownNames = new Set(componentEntries.map((c) => c.id));
101
90
  for (const entry of componentEntries) {
102
- const schemaPath = path.join(outDir, entry.schemaFile);
103
- const schemaRaw = await import('node:fs/promises').then((fs) => fs.readFile(schemaPath, 'utf-8'));
104
- const schema = JSON.parse(schemaRaw);
105
- const nestedBlocks = collectNestedBlockRefs(schema, knownNames, entry.name);
106
- if (nestedBlocks.length > 0) {
107
- entry.nestedBlocks = nestedBlocks;
91
+ for (const [slotName, slot] of Object.entries(entry.slots ?? {})) {
92
+ for (const childName of slot.allowed) {
93
+ if (!knownNames.has(childName)) {
94
+ throw new Error(`${entry.id}.slots.${slotName}.allowed references unknown component "${childName}"`);
95
+ }
96
+ }
108
97
  }
109
98
  }
110
99
 
@@ -117,6 +106,10 @@ async function compileRegistry(options) {
117
106
  };
118
107
  const registryPath = path.join(outDir, 'registry.json');
119
108
  await writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n');
109
+
110
+ const pageSchemaPath = await generatePageSchema(schemaDir, componentEntries, outDir);
111
+ schemaFiles.push(pageSchemaPath);
112
+
120
113
  return { registry: registryPath, schemas: schemaFiles };
121
114
  }
122
115
 
@@ -144,33 +137,82 @@ function validateDataModel(dataModel, index, seen) {
144
137
 
145
138
  function validateComponent(component, index, seen) {
146
139
  if (!component || typeof component !== 'object') throw new Error(`components[${index}] must be an object`);
147
- if (!component.name) throw new Error(`components[${index}].name is required`);
148
- if (seen.has(component.name)) throw new Error(`duplicate component name ${component.name}`);
149
- seen.add(component.name);
150
- if (!component.schema) throw new Error(`components[${index}].schema is required`);
151
- validateZodSubset(component.schema, `components[${index}].schema`);
140
+ if (!component.id) throw new Error(`components[${index}].id is required`);
141
+ if (seen.has(component.id)) throw new Error(`duplicate component id ${component.id}`);
142
+ seen.add(component.id);
143
+ if (!component.props) throw new Error(`components[${index}].props is required`);
144
+ validateZodSubset(component.props, `components[${index}].props`);
152
145
  validateImageSlotSchemaMatch(component, index);
153
- const renderModes = component.renderModes ?? component.render;
154
- if (!renderModes) throw new Error(`components[${index}].renderModes is required`);
155
- const enabled = Boolean(renderModes.canStatic ?? renderModes.static)
156
- || Boolean(renderModes.canSSR ?? renderModes.ssr)
157
- || Boolean(renderModes.canClientIsland ?? renderModes.clientIsland);
158
- if (!enabled) throw new Error(`components[${index}].renderModes must enable at least one mode`);
159
146
  }
160
147
 
161
148
  function validateImageSlotSchemaMatch(component, index) {
162
149
  const imageSlots = normalizeImageSlots(component.imageSlots);
163
- const imageFields = collectGradialImageFields(component.schema);
150
+ const imageFields = collectImageFields(component.props);
164
151
  for (const field of imageFields) {
165
152
  if (!Object.prototype.hasOwnProperty.call(imageSlots, field)) {
166
- throw new Error(`components[${index}].imageSlots.${field} is required because schema field ${field} uses GradialImageSchema`);
153
+ throw new Error(`components[${index}].imageSlots.${field} is required because props field ${field} uses an image schema`);
167
154
  }
168
155
  }
169
156
  for (const field of Object.keys(imageSlots)) {
170
157
  if (!imageFields.has(field)) {
171
- throw new Error(`components[${index}].schema.${field} must use GradialImageSchema because imageSlots.${field} is declared`);
158
+ throw new Error(`components[${index}].props.${field} must use an image schema because imageSlots.${field} is declared`);
159
+ }
160
+ }
161
+ }
162
+
163
+ function extractBlockSlots(schema, componentId) {
164
+ const shape = getSchemaShape(schema);
165
+ if (!shape) return {};
166
+ const out = {};
167
+ for (const [name, fieldSchema] of Object.entries(shape)) {
168
+ const unwrapped = unwrapOptionalSchema(fieldSchema);
169
+ const meta = getBlockRefMeta(unwrapped) ?? getBlockRefMeta(unwrapArraySchema(unwrapped));
170
+ if (meta) {
171
+ out[name] = {
172
+ allowed: [...meta.allowed],
173
+ multiple: meta.multiple,
174
+ required: false,
175
+ };
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function unwrapOptionalSchema(schema) {
182
+ let current = schema;
183
+ const seen = new Set();
184
+ while (current && typeof current === 'object' && !seen.has(current)) {
185
+ seen.add(current);
186
+ const def = current._def;
187
+ if (!def || typeof def !== 'object') break;
188
+ const type = def.type;
189
+ const typeName = def.typeName;
190
+ if (type === 'optional' || type === 'nullable' || type === 'default' ||
191
+ typeName === 'ZodOptional' || typeName === 'ZodNullable' || typeName === 'ZodDefault') {
192
+ const inner = def.innerType ?? def.schema;
193
+ if (inner) { current = inner; continue; }
172
194
  }
195
+ break;
173
196
  }
197
+ return current;
198
+ }
199
+
200
+ function getSchemaShape(schema) {
201
+ if (!schema || typeof schema !== 'object') return null;
202
+ const def = schema._def;
203
+ if (!def) return null;
204
+ const shape = typeof def.shape === 'function' ? def.shape() : def.shape;
205
+ if (shape && typeof shape === 'object') return shape;
206
+ return null;
207
+ }
208
+
209
+ function unwrapArraySchema(schema) {
210
+ if (!schema || typeof schema !== 'object') return null;
211
+ const def = schema._def;
212
+ if (!def) return null;
213
+ if (def.type === 'array' && def.element) return def.element;
214
+ if (def.typeName === 'ZodArray' && def.type) return def.type;
215
+ return null;
174
216
  }
175
217
 
176
218
  function normalizeImageSlots(imageSlots) {
@@ -230,34 +272,34 @@ function normalizeSlotOutput(slotKey, output, outputIndex) {
230
272
  };
231
273
  }
232
274
 
233
- function collectGradialImageFields(schema) {
275
+ function collectImageFields(schema) {
234
276
  const out = new Set();
235
277
 
236
- collectGradialImageFieldsFromSchema(schema, '', out);
278
+ collectImageFieldsFromSchema(schema, '', out);
237
279
 
238
280
  return out;
239
281
  }
240
282
 
241
- function collectGradialImageFieldsFromSchema(schema, prefix, out) {
283
+ function collectImageFieldsFromSchema(schema, prefix, out) {
242
284
  if (!schema) {
243
285
  return;
244
286
  }
245
287
 
246
- if (prefix && isGradialImageSchema(schema)) {
288
+ if (prefix && isImageSchema(schema)) {
247
289
  out.add(prefix);
248
290
  return;
249
291
  }
250
292
 
251
293
  const itemSchema = arrayItemSchema(schema);
252
294
  if (itemSchema) {
253
- collectGradialImageFieldsFromSchema(itemSchema, prefix, out);
295
+ collectImageFieldsFromSchema(itemSchema, prefix, out);
254
296
  return;
255
297
  }
256
298
 
257
299
  const unionOptions = unionOptionSchemas(schema);
258
300
  if (unionOptions) {
259
301
  for (const option of unionOptions) {
260
- collectGradialImageFieldsFromSchema(option, prefix, out);
302
+ collectImageFieldsFromSchema(option, prefix, out);
261
303
  }
262
304
  return;
263
305
  }
@@ -270,7 +312,7 @@ function collectGradialImageFieldsFromSchema(schema, prefix, out) {
270
312
 
271
313
  for (const [field, fieldSchema] of Object.entries(shape)) {
272
314
  const nextPrefix = prefix ? `${prefix}.${field}` : field;
273
- collectGradialImageFieldsFromSchema(fieldSchema, nextPrefix, out);
315
+ collectImageFieldsFromSchema(fieldSchema, nextPrefix, out);
274
316
  }
275
317
  }
276
318
 
@@ -301,21 +343,27 @@ function arrayItemSchema(schema) {
301
343
  return null;
302
344
  }
303
345
 
304
- function isGradialImageSchema(schema) {
346
+ function isImageSchema(schema) {
305
347
  const unwrapped = unwrapSchema(schema);
306
- if (unwrapped === GradialImageSchema) return true;
348
+ if (unwrapped === ImageSchema) return true;
307
349
  try {
308
350
  const compiled = z.toJSONSchema(unwrapped, {
309
351
  target: 'draft-2020-12',
310
352
  unrepresentable: 'throw',
311
353
  });
312
- const sources = compiled?.properties?.sources;
313
- const sourcesIsArray = sources?.type === 'array'
314
- || (Array.isArray(sources?.anyOf) && sources.anyOf.some((s) => s?.type === 'array'));
354
+ const properties = compiled?.properties ?? {};
355
+ const required = new Set(compiled?.required ?? []);
315
356
  return compiled?.type === 'object'
316
- && compiled?.properties?.$type?.const === 'gradial.image'
317
- && compiled?.properties?.fallback?.type === 'object'
318
- && sourcesIsArray;
357
+ && properties.$type?.const === 'gradial.image'
358
+ && properties.assetId?.type === 'string'
359
+ && properties.versionId?.type === 'string'
360
+ && properties.alt?.type === 'string'
361
+ && properties.sources?.type === 'array'
362
+ && required.has('$type')
363
+ && required.has('assetId')
364
+ && required.has('versionId')
365
+ && required.has('alt')
366
+ && required.has('sources');
319
367
  } catch {
320
368
  return false;
321
369
  }
@@ -326,7 +374,7 @@ function unwrapSchema(schema) {
326
374
  const seen = new Set();
327
375
  while (current && !seen.has(current)) {
328
376
  seen.add(current);
329
- if (current === GradialImageSchema) return current;
377
+ if (current === ImageSchema) return current;
330
378
  const def = current._def;
331
379
  const next = def?.innerType ?? def?.schema;
332
380
  if (!next) break;
@@ -349,7 +397,11 @@ function validateLayout(layout, index, seen) {
349
397
  seenSlots.add(slot.name);
350
398
  slots.push({ name: slot.name, required: Boolean(slot.required) });
351
399
  }
352
- return { name: layout.name, slots };
400
+ const entry = { name: layout.name, slots };
401
+ if (layout.defaults && typeof layout.defaults === 'object') {
402
+ entry.defaults = layout.defaults;
403
+ }
404
+ return entry;
353
405
  }
354
406
 
355
407
  function compileSchema(schema, name) {
@@ -368,6 +420,131 @@ function compileSchema(schema, name) {
368
420
  };
369
421
  }
370
422
 
423
+ async function generatePageSchema(schemaDir, componentEntries, outDir) {
424
+ const { readFile } = await import('node:fs/promises');
425
+ const defs = {
426
+ baseBlock: {
427
+ type: 'object',
428
+ required: ['id', 'component', 'props'],
429
+ additionalProperties: true,
430
+ properties: {
431
+ id: { type: 'string', minLength: 1 },
432
+ component: { type: 'string' },
433
+ props: { type: 'object' },
434
+ slots: {
435
+ type: 'object',
436
+ additionalProperties: {
437
+ type: 'array',
438
+ items: { $ref: '#/$defs/baseBlock' },
439
+ },
440
+ },
441
+ },
442
+ },
443
+ damAssetRef: {
444
+ type: 'object',
445
+ required: ['$type', 'assetId'],
446
+ additionalProperties: true,
447
+ properties: {
448
+ $type: { const: 'dam.assetRef' },
449
+ assetId: { type: 'string', minLength: 1 },
450
+ version: { type: 'string', minLength: 1 },
451
+ alt: { type: 'string' },
452
+ },
453
+ },
454
+ };
455
+ const oneOf = [];
456
+ for (const entry of componentEntries) {
457
+ const schemaPath = path.join(outDir, entry.props);
458
+ const raw = await readFile(schemaPath, 'utf-8');
459
+ const componentSchema = JSON.parse(raw);
460
+ const { $schema: _s, title: _t, ...propsSchema } = componentSchema;
461
+ replaceImagesWithDamAssetRef(propsSchema, defs.damAssetRef);
462
+ const defName = `${entry.name}Block`;
463
+ defs[defName] = {
464
+ allOf: [
465
+ { $ref: '#/$defs/baseBlock' },
466
+ {
467
+ properties: {
468
+ component: { const: entry.name },
469
+ props: propsSchema,
470
+ },
471
+ },
472
+ ],
473
+ };
474
+ oneOf.push({ $ref: `#/$defs/${defName}` });
475
+ }
476
+ const pageSchema = {
477
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
478
+ $id: 'https://baremetal.local/schemas/page.schema.json',
479
+ title: 'bare-metal Page',
480
+ type: 'object',
481
+ required: ['$type', 'id', 'status', 'layout', 'metadata', 'regions'],
482
+ additionalProperties: true,
483
+ properties: {
484
+ $type: { const: 'page' },
485
+ id: { type: 'string', minLength: 1 },
486
+ status: { type: 'string', minLength: 1 },
487
+ layout: { type: 'string', minLength: 1 },
488
+ metadata: {
489
+ type: 'object',
490
+ required: ['title'],
491
+ additionalProperties: true,
492
+ properties: {
493
+ title: { type: 'string', minLength: 1 },
494
+ description: { type: 'string' },
495
+ canonical: { type: 'string' },
496
+ },
497
+ },
498
+ regions: {
499
+ type: 'object',
500
+ additionalProperties: {
501
+ type: 'array',
502
+ items: { oneOf },
503
+ },
504
+ },
505
+ },
506
+ $defs: defs,
507
+ };
508
+ const pageSchemaPath = path.join(schemaDir, 'page.schema.json');
509
+ await writeFile(pageSchemaPath, JSON.stringify(pageSchema, null, 2) + '\n');
510
+ return pageSchemaPath;
511
+ }
512
+
513
+ function replaceImagesWithDamAssetRef(schema, damAssetRef) {
514
+ if (!schema || typeof schema !== 'object') return schema;
515
+ if (schema.$ref) return;
516
+ if (Array.isArray(schema.anyOf)) {
517
+ for (const s of schema.anyOf) replaceImagesWithDamAssetRef(s, damAssetRef);
518
+ }
519
+ if (Array.isArray(schema.allOf)) {
520
+ for (const s of schema.allOf) replaceImagesWithDamAssetRef(s, damAssetRef);
521
+ }
522
+ if (Array.isArray(schema.oneOf)) {
523
+ for (const s of schema.oneOf) replaceImagesWithDamAssetRef(s, damAssetRef);
524
+ }
525
+ if (schema.properties) {
526
+ for (const [key, value] of Object.entries(schema.properties)) {
527
+ if (isJsonImage(value)) {
528
+ schema.properties[key] = { $ref: '#/$defs/damAssetRef' };
529
+ } else {
530
+ replaceImagesWithDamAssetRef(value, damAssetRef);
531
+ }
532
+ }
533
+ }
534
+ if (schema.items) {
535
+ if (isJsonImage(schema.items)) {
536
+ schema.items = { $ref: '#/$defs/damAssetRef' };
537
+ } else {
538
+ replaceImagesWithDamAssetRef(schema.items, damAssetRef);
539
+ }
540
+ }
541
+ }
542
+
543
+ function isJsonImage(node) {
544
+ return node?.type === 'object'
545
+ && node?.properties?.$type?.const === 'gradial.image';
546
+ }
547
+
371
548
  function validateZodSubset(schema, pathLabel, seen = new Set()) {
372
549
  if (seen.has(schema)) return;
373
550
  seen.add(schema);
@@ -53,20 +53,17 @@ async function validateContent(options) {
53
53
  const registry = JSON.parse(registryRaw);
54
54
 
55
55
  // Build schema map: component name → JSON Schema object
56
- // Build nested blocks map: component name → array of { path, allow? }
57
56
  const schemaMap = new Map();
58
57
  const knownComponents = new Set();
59
- const nestedBlocksMap = new Map();
58
+ const componentMap = new Map();
60
59
  for (const component of registry.components) {
61
60
  knownComponents.add(component.name);
62
- if (component.schemaFile) {
63
- const schemaPath = path.join(registryDir, component.schemaFile);
61
+ componentMap.set(component.name, component);
62
+ if (component.props) {
63
+ const schemaPath = path.join(registryDir, component.props);
64
64
  const schemaRaw = await readFile(schemaPath, 'utf-8');
65
65
  schemaMap.set(component.name, JSON.parse(schemaRaw));
66
66
  }
67
- if (Array.isArray(component.nestedBlocks) && component.nestedBlocks.length > 0) {
68
- nestedBlocksMap.set(component.name, component.nestedBlocks);
69
- }
70
67
  }
71
68
 
72
69
  // Find all compiled route files
@@ -134,10 +131,7 @@ async function validateContent(options) {
134
131
  });
135
132
  }
136
133
 
137
- // Recursively validate nested blocks (blockRef patterns)
138
- const nestedIssues = validateNestedBlocks(
139
- block.component, props, knownComponents, schemaMap, nestedBlocksMap,
140
- );
134
+ const nestedIssues = validateNestedSlots(block, componentMap, schemaMap);
141
135
  for (const issue of nestedIssues) {
142
136
  collector.push({
143
137
  page: relativePath,
@@ -360,83 +354,52 @@ function resolveRef(ref, rootSchema) {
360
354
  // ---------------------------------------------------------------------------
361
355
 
362
356
  // ---------------------------------------------------------------------------
363
- // Nested block validation — recursively validates blocks inside blockRef slots
357
+ // Block ref validation — recursively validates nested blocks inside props.
364
358
  // ---------------------------------------------------------------------------
365
359
 
366
- function validateNestedBlocks(parentComponent, props, knownComponents, schemaMap, nestedBlocksMap) {
360
+ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props') {
367
361
  const issues = [];
368
- // Find all block-shaped arrays in props by walking the data
369
- const nestedBlocks = findNestedBlockArrays(props);
370
- for (const { path: blockPath, blocks } of nestedBlocks) {
362
+ const parentComponent = componentMap.get(parentBlock.component);
363
+ if (!parentComponent) return issues;
364
+
365
+ const blockSlots = parentComponent.slots ?? {};
366
+ for (const [slotName, slot] of Object.entries(blockSlots)) {
367
+ const blocks = parentBlock.props?.[slotName];
368
+ if (!Array.isArray(blocks)) {
369
+ if (slot.required) {
370
+ issues.push({ path: `${path}.${slotName}`, message: `${parentBlock.component}: required block ref "${slotName}" has no children` });
371
+ }
372
+ continue;
373
+ }
374
+ if (!slot.multiple && blocks.length > 1) {
375
+ issues.push({ path: `${path}.${slotName}`, message: `${parentBlock.component}: block ref "${slotName}" allows at most one child` });
376
+ }
371
377
  for (const [index, nestedBlock] of blocks.entries()) {
372
- if (!nestedBlock || typeof nestedBlock !== 'object') continue;
373
- const childComponent = nestedBlock.component;
374
- if (!childComponent || typeof childComponent !== 'string') continue;
375
-
376
- const nestedPath = `${blockPath}[${index}]`;
377
-
378
- // Check nested component exists in registry
379
- if (!knownComponents.has(childComponent)) {
380
- issues.push({
381
- path: `${nestedPath}.component`,
382
- message: `${parentComponent} → ${nestedPath}: unknown nested component "${childComponent}"`,
383
- });
378
+ const nestedPath = `${path}.${slotName}[${index}]`;
379
+ if (!nestedBlock || typeof nestedBlock !== 'object' || typeof nestedBlock.component !== 'string') {
380
+ issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
384
381
  continue;
385
382
  }
386
383
 
387
- // Validate nested block's props against its own schema
388
- const childSchema = schemaMap.get(childComponent);
389
- if (!childSchema) continue;
390
-
391
- const childProps = nestedBlock.props ?? {};
392
- const childIssues = validateJsonSchema(childProps, childSchema, `${nestedPath}.props`);
393
- for (const issue of childIssues) {
394
- issues.push({
395
- nestedComponent: childComponent,
396
- path: issue.path,
397
- message: `${parentComponent} → ${childComponent}: ${issue.message}`,
398
- });
384
+ if (!componentMap.has(nestedBlock.component)) {
385
+ issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
386
+ continue;
399
387
  }
400
-
401
- // Recurse further if the nested component itself has nested blocks
402
- const deeperIssues = validateNestedBlocks(
403
- childComponent, childProps, knownComponents, schemaMap, nestedBlocksMap,
404
- );
405
- issues.push(...deeperIssues);
406
- }
407
- }
408
- return issues;
409
- }
410
-
411
- /**
412
- * Walks a props object looking for arrays of block-shaped objects
413
- * (objects with `component` + `props` fields).
414
- */
415
- function findNestedBlockArrays(obj, prefix = 'props') {
416
- const results = [];
417
- if (!obj || typeof obj !== 'object') return results;
418
-
419
- for (const [key, value] of Object.entries(obj)) {
420
- const currentPath = `${prefix}.${key}`;
421
- if (Array.isArray(value)) {
422
- // Check if array contains block-shaped objects
423
- const hasBlocks = value.some(
424
- (item) => item && typeof item === 'object' && typeof item.component === 'string' && 'props' in item,
425
- );
426
- if (hasBlocks) {
427
- results.push({ path: currentPath, blocks: value });
388
+ if (!slot.allowed.includes(nestedBlock.component)) {
389
+ issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: component "${nestedBlock.component}" is not allowed in block ref "${slotName}"` });
428
390
  }
429
- // Also recurse into array items that are objects (for nested structures like tabs[].blocks[])
430
- for (const item of value) {
431
- if (item && typeof item === 'object' && !Array.isArray(item)) {
432
- results.push(...findNestedBlockArrays(item, currentPath + '[]'));
391
+
392
+ const childSchema = schemaMap.get(nestedBlock.component);
393
+ const childProps = nestedBlock.props ?? {};
394
+ if (childSchema) {
395
+ for (const issue of validateJsonSchema(childProps, childSchema, `${nestedPath}.props`)) {
396
+ issues.push({ nestedComponent: nestedBlock.component, path: issue.path, message: `${parentBlock.component} → ${nestedBlock.component}: ${issue.message}` });
433
397
  }
434
398
  }
435
- } else if (typeof value === 'object' && value !== null) {
436
- results.push(...findNestedBlockArrays(value, currentPath));
399
+ issues.push(...validateNestedSlots(nestedBlock, componentMap, schemaMap, nestedPath));
437
400
  }
438
401
  }
439
- return results;
402
+ return issues;
440
403
  }
441
404
 
442
405
  function collectBlocks(pageData) {