@gradial/aci 0.1.20-preview.3 → 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 (224) hide show
  1. package/README.md +48 -108
  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 +12 -3
  7. package/dist/astro/index.js +20 -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 +1 -0
  11. package/dist/content/index.js +1 -0
  12. package/dist/define-component.d.ts +1 -2
  13. package/dist/define-component.js +73 -5
  14. package/dist/dev/content-watch.d.ts +37 -0
  15. package/dist/dev/content-watch.js +154 -0
  16. package/dist/dev/index.d.ts +24 -43
  17. package/dist/dev/index.js +4 -169
  18. package/dist/index.d.ts +14 -6
  19. package/dist/index.js +8 -9
  20. package/dist/next/asset-route.js +2 -2
  21. package/dist/next/config.d.ts +2 -2
  22. package/dist/next/config.js +5 -5
  23. package/dist/next/content-watch.js +6 -1
  24. package/dist/next/index.d.ts +2 -2
  25. package/dist/next/index.js +2 -2
  26. package/dist/next/middleware.d.ts +2 -2
  27. package/dist/next/middleware.js +6 -6
  28. package/dist/next/page.d.ts +1 -1
  29. package/dist/next/page.js +14 -12
  30. package/dist/next/render.d.ts +3 -3
  31. package/dist/next/render.js +11 -9
  32. package/dist/next/server.d.ts +12 -12
  33. package/dist/next/server.js +1 -1
  34. package/dist/react/Media.d.ts +24 -0
  35. package/dist/react/Media.js +30 -0
  36. package/dist/react/Picture.d.ts +12 -0
  37. package/dist/react/Picture.js +11 -0
  38. package/dist/react/VideoPlayer.d.ts +13 -0
  39. package/dist/react/{GradialVideoPlayer.js → VideoPlayer.js} +6 -5
  40. package/dist/react/index.d.ts +333 -3
  41. package/dist/react/index.js +89 -3
  42. package/dist/registry.d.ts +2 -2
  43. package/dist/registry.js +3 -3
  44. package/dist/types/block-ref.d.ts +26 -0
  45. package/dist/types/block-ref.js +28 -0
  46. package/dist/types/component.d.ts +36 -32
  47. package/dist/types/config.d.ts +1 -1
  48. package/dist/types/image.d.ts +33 -20
  49. package/dist/types/image.js +42 -50
  50. package/dist/types/index.d.ts +1 -0
  51. package/dist/types/index.js +1 -0
  52. package/dist/types/link.d.ts +38 -0
  53. package/dist/types/link.js +58 -0
  54. package/dist/types/media.d.ts +16 -35
  55. package/dist/types/media.js +15 -84
  56. package/dist/types/renderer.d.ts +1 -1
  57. package/dist/types/rich-text.d.ts +40 -0
  58. package/dist/types/rich-text.js +39 -0
  59. package/dist/types/video.d.ts +7 -13
  60. package/dist/types/video.js +3 -3
  61. package/package.json +28 -76
  62. package/src/cli/compile-registry.mjs +120 -63
  63. package/src/cli/validate-content.mjs +38 -75
  64. package/src/types/image.ts +79 -60
  65. package/templates/astro/template/.aci.yaml +27 -0
  66. package/templates/astro/template/.storybook/main.ts +79 -0
  67. package/templates/astro/template/.storybook/manager.ts +6 -0
  68. package/templates/astro/template/.storybook/preview.ts +33 -0
  69. package/templates/astro/template/astro.config.mjs +37 -0
  70. package/templates/astro/template/package.json.tmpl +41 -0
  71. package/templates/astro/template/src/cms/contracts/layouts/index.ts +18 -0
  72. package/templates/astro/template/src/cms/registry.ts +28 -0
  73. package/templates/astro/template/src/components/chrome/Footer.astro +107 -0
  74. package/templates/astro/template/src/components/chrome/Header.astro +94 -0
  75. package/templates/astro/template/src/components/elements/AtomicBlock.astro +15 -0
  76. package/templates/astro/template/src/components/elements/AtomicBlocks.astro +16 -0
  77. package/templates/astro/template/src/components/elements/Button.astro +51 -0
  78. package/templates/astro/template/src/components/elements/ButtonBlock.astro +25 -0
  79. package/templates/astro/template/src/components/elements/Container.astro +11 -0
  80. package/templates/astro/template/src/components/elements/Icon.astro +31 -0
  81. package/templates/astro/template/src/components/elements/Logo.astro +29 -0
  82. package/templates/astro/template/src/components/elements/RemoteImage.astro +41 -0
  83. package/templates/astro/template/src/components/sections/ContainerSection.astro +32 -0
  84. package/templates/astro/template/src/components/sections/FeatureCard.astro +23 -0
  85. package/templates/astro/template/src/components/sections/FeatureGrid.astro +35 -0
  86. package/templates/astro/template/src/components/sections/HomeHero.astro +51 -0
  87. package/templates/astro/template/src/components/sections/StatsBar.astro +33 -0
  88. package/templates/astro/template/src/design-system/styles.css +399 -0
  89. package/templates/astro/template/src/design-system/tokens/colors.css +60 -0
  90. package/templates/astro/template/src/design-system/tokens/components.css +69 -0
  91. package/templates/astro/template/src/design-system/tokens/index.css +6 -0
  92. package/templates/astro/template/src/design-system/tokens/radii.css +5 -0
  93. package/templates/astro/template/src/design-system/tokens/shadows.css +5 -0
  94. package/templates/astro/template/src/design-system/tokens/spacing.css +13 -0
  95. package/templates/astro/template/src/design-system/tokens/typography.css +24 -0
  96. package/templates/astro/template/src/env.d.ts +1 -0
  97. package/templates/astro/template/src/fixtures/atomicBlocks.ts +33 -0
  98. package/templates/astro/template/src/fixtures/composition.ts +31 -0
  99. package/templates/astro/template/src/fixtures/index.ts +17 -0
  100. package/templates/astro/template/src/fixtures/sections.ts +39 -0
  101. package/templates/astro/template/src/layouts/SiteLayout.astro +30 -0
  102. package/templates/astro/template/src/pages/[...slug].astro +61 -0
  103. package/templates/astro/template/src/render/RenderPage.astro +66 -0
  104. package/templates/astro/template/src/stories/CompositionStoryFrame.astro +15 -0
  105. package/templates/astro/template/src/stories/HomeHero.stories.ts +24 -0
  106. package/templates/astro/template/src/stories/SectionStoryFrame.astro +16 -0
  107. package/templates/astro/template/src/stories/StoryDocFrame.astro +31 -0
  108. package/templates/astro/template/tsconfig.json +11 -0
  109. package/templates/common/.agents/AGENTS.md +37 -0
  110. package/templates/common/.agents/skills/aci/SKILL.md +162 -0
  111. package/templates/common/.agents/skills/authoring/SKILL.md +590 -0
  112. package/templates/common/.agents/skills/authoring/references/block-registry.md +151 -0
  113. package/templates/common/.agents/skills/authoring/references/contracts.md +153 -0
  114. package/templates/common/.agents/skills/authoring/references/design-system.md +171 -0
  115. package/templates/common/.agents/skills/authoring/references/image-assets.md +73 -0
  116. package/templates/common/.agents/skills/authoring/references/repo-structure.md +157 -0
  117. package/templates/common/.agents/skills/migration/SKILL.md +160 -0
  118. package/templates/common/.agents/skills/migration/references/analyzing-source.md +215 -0
  119. package/templates/common/.agents/skills/migration/references/component-migration.md +200 -0
  120. package/templates/common/.agents/skills/migration/references/selector-map.md +31 -0
  121. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  122. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  123. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  124. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  125. package/templates/common/.content/config/site.json +192 -0
  126. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +9 -0
  127. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +14 -0
  128. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +23 -0
  129. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +37 -0
  130. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +9 -0
  131. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +14 -0
  132. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +23 -0
  133. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +37 -0
  134. package/templates/common/.content/fragments/navbar/_index.json +25 -0
  135. package/templates/common/.content/fragments/site-footer/_index.json +62 -0
  136. package/templates/common/.content/pages/about/_index.json +51 -0
  137. package/templates/common/.content/pages/home/_index.json +112 -0
  138. package/templates/common/.content/schemas/site.schema.json +123 -0
  139. package/templates/common/README.md.tmpl +45 -0
  140. package/templates/common/contracts/components/button.contract.ts +19 -0
  141. package/templates/common/contracts/components/container.contract.ts +19 -0
  142. package/templates/common/contracts/components/featureCard.contract.ts +15 -0
  143. package/templates/common/contracts/components/featureGrid.contract.ts +16 -0
  144. package/templates/common/contracts/components/homeHero.contract.ts +36 -0
  145. package/templates/common/contracts/components/index.ts +21 -0
  146. package/templates/common/contracts/components/siteFooter.contract.ts +45 -0
  147. package/templates/common/contracts/components/siteNavigation.contract.ts +30 -0
  148. package/templates/common/contracts/components/statsBar.contract.ts +19 -0
  149. package/templates/common/design-system/styles.css +346 -0
  150. package/templates/common/design-system/tokens/colors.css +110 -0
  151. package/templates/common/design-system/tokens/components.css +86 -0
  152. package/templates/common/design-system/tokens/index.css +6 -0
  153. package/templates/common/design-system/tokens/radii.css +5 -0
  154. package/templates/common/design-system/tokens/shadows.css +9 -0
  155. package/templates/common/design-system/tokens/spacing.css +26 -0
  156. package/templates/common/design-system/tokens/typography.css +32 -0
  157. package/templates/common/eslint-rules/no-raw-tailwind-values.js +127 -0
  158. package/templates/common/public/assets/Dwire-model.png +0 -0
  159. package/templates/common/public/assets/bare-metal-logo.png +0 -0
  160. package/templates/common/public/assets/meridian-logo.svg +8 -0
  161. package/templates/common/public/assets/seattle.avif +0 -0
  162. package/templates/nextjs/template/.aci.yaml +25 -0
  163. package/templates/nextjs/template/.storybook/main.ts +13 -0
  164. package/templates/nextjs/template/.storybook/preview.ts +34 -0
  165. package/templates/nextjs/template/eslint.config.mjs +36 -0
  166. package/templates/nextjs/template/next-env.d.ts +6 -0
  167. package/templates/nextjs/template/next.config.ts +8 -0
  168. package/templates/nextjs/template/package.json.tmpl +46 -0
  169. package/templates/nextjs/template/postcss.config.mjs +7 -0
  170. package/templates/nextjs/template/src/app/[[...slug]]/page.tsx +8 -0
  171. package/templates/nextjs/template/src/app/gradial/assets/[releaseId]/[...path]/route.ts +31 -0
  172. package/templates/nextjs/template/src/app/layout.tsx +29 -0
  173. package/templates/nextjs/template/src/cms/contracts/components/button.contract.ts +19 -0
  174. package/templates/nextjs/template/src/cms/contracts/components/container.contract.ts +20 -0
  175. package/templates/nextjs/template/src/cms/contracts/components/homeHero.contract.ts +36 -0
  176. package/templates/nextjs/template/src/cms/contracts/components/index.ts +15 -0
  177. package/templates/nextjs/template/src/cms/contracts/components/siteFooter.contract.ts +45 -0
  178. package/templates/nextjs/template/src/cms/contracts/components/siteNavigation.contract.ts +30 -0
  179. package/templates/nextjs/template/src/cms/contracts/layouts/index.ts +16 -0
  180. package/templates/nextjs/template/src/cms/registry.ts +28 -0
  181. package/templates/nextjs/template/src/components/chrome/Footer.tsx +86 -0
  182. package/templates/nextjs/template/src/components/chrome/Header.tsx +74 -0
  183. package/templates/nextjs/template/src/components/elements/AtomicBlock.tsx +13 -0
  184. package/templates/nextjs/template/src/components/elements/Button.tsx +40 -0
  185. package/templates/nextjs/template/src/components/elements/ButtonBlock.tsx +18 -0
  186. package/templates/nextjs/template/src/components/elements/Container.tsx +14 -0
  187. package/templates/nextjs/template/src/components/elements/Icon.tsx +29 -0
  188. package/templates/nextjs/template/src/components/elements/Logo.tsx +38 -0
  189. package/templates/nextjs/template/src/components/sections/ContainerSection.tsx +28 -0
  190. package/templates/nextjs/template/src/components/sections/FeatureCard.tsx +25 -0
  191. package/templates/nextjs/template/src/components/sections/FeatureGrid.tsx +36 -0
  192. package/templates/nextjs/template/src/components/sections/HomeHero.tsx +62 -0
  193. package/templates/nextjs/template/src/components/sections/StatsBar.tsx +30 -0
  194. package/templates/nextjs/template/src/middleware.ts +4 -0
  195. package/templates/nextjs/template/src/stories/Button.stories.tsx +38 -0
  196. package/templates/nextjs/template/src/stories/Header.stories.tsx +30 -0
  197. package/templates/nextjs/template/src/stories/HomeHero.stories.tsx +33 -0
  198. package/templates/nextjs/template/tsconfig.json +23 -0
  199. package/templates/nextjs/template/vercel.json +6 -0
  200. package/dist/content/contract.d.ts +0 -83
  201. package/dist/content/contract.js +0 -104
  202. package/dist/content/tailwind-validator.d.ts +0 -6
  203. package/dist/content/tailwind-validator.js +0 -31
  204. package/dist/content/validation.d.ts +0 -108
  205. package/dist/content/validation.js +0 -183
  206. package/dist/react/GradialMedia.d.ts +0 -24
  207. package/dist/react/GradialMedia.js +0 -31
  208. package/dist/react/GradialPicture.d.ts +0 -14
  209. package/dist/react/GradialPicture.js +0 -30
  210. package/dist/react/GradialVideoPlayer.d.ts +0 -13
  211. package/dist/sveltekit/index.d.ts +0 -25
  212. package/dist/sveltekit/index.js +0 -66
  213. package/dist/sveltekit/preview.d.ts +0 -5
  214. package/dist/sveltekit/preview.js +0 -33
  215. package/src/types/component.ts +0 -49
  216. package/src/types/config.ts +0 -37
  217. package/src/types/data.ts +0 -47
  218. package/src/types/index.ts +0 -10
  219. package/src/types/layout.ts +0 -29
  220. package/src/types/media.ts +0 -125
  221. package/src/types/page.ts +0 -47
  222. package/src/types/render-mode.ts +0 -10
  223. package/src/types/renderer.ts +0 -83
  224. 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 varyDimensions = component.varyDimensions ?? component.vary;
56
57
  const imageSlots = normalizeImageSlots(component.imageSlots);
57
- const renderModes = component.renderModes ?? component.render;
58
+ const slots = extractBlockSlots(component.props, componentId);
58
59
  componentEntries.push({
59
- name: component.name,
60
- schemaFile,
61
- render: renderModes ? {
62
- static: Boolean(renderModes.canStatic ?? renderModes.static),
63
- ssr: Boolean(renderModes.canSSR ?? renderModes.ssr),
64
- clientIsland: Boolean(renderModes.canClientIsland ?? renderModes.clientIsland),
65
- } : { static: true, ssr: true, clientIsland: false },
66
- renderModes: renderModes ? {
67
- canStatic: Boolean(renderModes.canStatic ?? renderModes.static),
68
- canSSR: Boolean(renderModes.canSSR ?? renderModes.ssr),
69
- canClientIsland: Boolean(renderModes.canClientIsland ?? renderModes.clientIsland),
70
- } : { canStatic: true, canSSR: true, canClientIsland: false },
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
 
@@ -148,29 +137,84 @@ function validateDataModel(dataModel, index, seen) {
148
137
 
149
138
  function validateComponent(component, index, seen) {
150
139
  if (!component || typeof component !== 'object') throw new Error(`components[${index}] must be an object`);
151
- if (!component.name) throw new Error(`components[${index}].name is required`);
152
- if (seen.has(component.name)) throw new Error(`duplicate component name ${component.name}`);
153
- seen.add(component.name);
154
- if (!component.schema) throw new Error(`components[${index}].schema is required`);
155
- 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`);
156
145
  validateImageSlotSchemaMatch(component, index);
157
146
  }
158
147
 
159
148
  function validateImageSlotSchemaMatch(component, index) {
160
149
  const imageSlots = normalizeImageSlots(component.imageSlots);
161
- const imageFields = collectGradialImageFields(component.schema);
150
+ const imageFields = collectImageFields(component.props);
162
151
  for (const field of imageFields) {
163
152
  if (!Object.prototype.hasOwnProperty.call(imageSlots, field)) {
164
- 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`);
165
154
  }
166
155
  }
167
156
  for (const field of Object.keys(imageSlots)) {
168
157
  if (!imageFields.has(field)) {
169
- 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`);
170
159
  }
171
160
  }
172
161
  }
173
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; }
194
+ }
195
+ break;
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;
216
+ }
217
+
174
218
  function normalizeImageSlots(imageSlots) {
175
219
  if (imageSlots == null) return {};
176
220
  if (typeof imageSlots !== 'object' || Array.isArray(imageSlots)) {
@@ -228,34 +272,34 @@ function normalizeSlotOutput(slotKey, output, outputIndex) {
228
272
  };
229
273
  }
230
274
 
231
- function collectGradialImageFields(schema) {
275
+ function collectImageFields(schema) {
232
276
  const out = new Set();
233
277
 
234
- collectGradialImageFieldsFromSchema(schema, '', out);
278
+ collectImageFieldsFromSchema(schema, '', out);
235
279
 
236
280
  return out;
237
281
  }
238
282
 
239
- function collectGradialImageFieldsFromSchema(schema, prefix, out) {
283
+ function collectImageFieldsFromSchema(schema, prefix, out) {
240
284
  if (!schema) {
241
285
  return;
242
286
  }
243
287
 
244
- if (prefix && isGradialImageSchema(schema)) {
288
+ if (prefix && isImageSchema(schema)) {
245
289
  out.add(prefix);
246
290
  return;
247
291
  }
248
292
 
249
293
  const itemSchema = arrayItemSchema(schema);
250
294
  if (itemSchema) {
251
- collectGradialImageFieldsFromSchema(itemSchema, prefix, out);
295
+ collectImageFieldsFromSchema(itemSchema, prefix, out);
252
296
  return;
253
297
  }
254
298
 
255
299
  const unionOptions = unionOptionSchemas(schema);
256
300
  if (unionOptions) {
257
301
  for (const option of unionOptions) {
258
- collectGradialImageFieldsFromSchema(option, prefix, out);
302
+ collectImageFieldsFromSchema(option, prefix, out);
259
303
  }
260
304
  return;
261
305
  }
@@ -268,7 +312,7 @@ function collectGradialImageFieldsFromSchema(schema, prefix, out) {
268
312
 
269
313
  for (const [field, fieldSchema] of Object.entries(shape)) {
270
314
  const nextPrefix = prefix ? `${prefix}.${field}` : field;
271
- collectGradialImageFieldsFromSchema(fieldSchema, nextPrefix, out);
315
+ collectImageFieldsFromSchema(fieldSchema, nextPrefix, out);
272
316
  }
273
317
  }
274
318
 
@@ -299,21 +343,27 @@ function arrayItemSchema(schema) {
299
343
  return null;
300
344
  }
301
345
 
302
- function isGradialImageSchema(schema) {
346
+ function isImageSchema(schema) {
303
347
  const unwrapped = unwrapSchema(schema);
304
- if (unwrapped === GradialImageSchema) return true;
348
+ if (unwrapped === ImageSchema) return true;
305
349
  try {
306
350
  const compiled = z.toJSONSchema(unwrapped, {
307
351
  target: 'draft-2020-12',
308
352
  unrepresentable: 'throw',
309
353
  });
310
- const sources = compiled?.properties?.sources;
311
- const sourcesIsArray = sources?.type === 'array'
312
- || (Array.isArray(sources?.anyOf) && sources.anyOf.some((s) => s?.type === 'array'));
354
+ const properties = compiled?.properties ?? {};
355
+ const required = new Set(compiled?.required ?? []);
313
356
  return compiled?.type === 'object'
314
- && compiled?.properties?.$type?.const === 'gradial.image'
315
- && compiled?.properties?.fallback?.type === 'object'
316
- && 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');
317
367
  } catch {
318
368
  return false;
319
369
  }
@@ -324,7 +374,7 @@ function unwrapSchema(schema) {
324
374
  const seen = new Set();
325
375
  while (current && !seen.has(current)) {
326
376
  seen.add(current);
327
- if (current === GradialImageSchema) return current;
377
+ if (current === ImageSchema) return current;
328
378
  const def = current._def;
329
379
  const next = def?.innerType ?? def?.schema;
330
380
  if (!next) break;
@@ -381,6 +431,13 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
381
431
  id: { type: 'string', minLength: 1 },
382
432
  component: { type: 'string' },
383
433
  props: { type: 'object' },
434
+ slots: {
435
+ type: 'object',
436
+ additionalProperties: {
437
+ type: 'array',
438
+ items: { $ref: '#/$defs/baseBlock' },
439
+ },
440
+ },
384
441
  },
385
442
  },
386
443
  damAssetRef: {
@@ -397,11 +454,11 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
397
454
  };
398
455
  const oneOf = [];
399
456
  for (const entry of componentEntries) {
400
- const schemaPath = path.join(outDir, entry.schemaFile);
457
+ const schemaPath = path.join(outDir, entry.props);
401
458
  const raw = await readFile(schemaPath, 'utf-8');
402
459
  const componentSchema = JSON.parse(raw);
403
460
  const { $schema: _s, title: _t, ...propsSchema } = componentSchema;
404
- replaceGradialImagesWithDamAssetRef(propsSchema, defs.damAssetRef);
461
+ replaceImagesWithDamAssetRef(propsSchema, defs.damAssetRef);
405
462
  const defName = `${entry.name}Block`;
406
463
  defs[defName] = {
407
464
  allOf: [
@@ -453,37 +510,37 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
453
510
  return pageSchemaPath;
454
511
  }
455
512
 
456
- function replaceGradialImagesWithDamAssetRef(schema, damAssetRef) {
513
+ function replaceImagesWithDamAssetRef(schema, damAssetRef) {
457
514
  if (!schema || typeof schema !== 'object') return schema;
458
515
  if (schema.$ref) return;
459
516
  if (Array.isArray(schema.anyOf)) {
460
- for (const s of schema.anyOf) replaceGradialImagesWithDamAssetRef(s, damAssetRef);
517
+ for (const s of schema.anyOf) replaceImagesWithDamAssetRef(s, damAssetRef);
461
518
  }
462
519
  if (Array.isArray(schema.allOf)) {
463
- for (const s of schema.allOf) replaceGradialImagesWithDamAssetRef(s, damAssetRef);
520
+ for (const s of schema.allOf) replaceImagesWithDamAssetRef(s, damAssetRef);
464
521
  }
465
522
  if (Array.isArray(schema.oneOf)) {
466
- for (const s of schema.oneOf) replaceGradialImagesWithDamAssetRef(s, damAssetRef);
523
+ for (const s of schema.oneOf) replaceImagesWithDamAssetRef(s, damAssetRef);
467
524
  }
468
525
  if (schema.properties) {
469
526
  for (const [key, value] of Object.entries(schema.properties)) {
470
- if (isJsonGradialImage(value)) {
527
+ if (isJsonImage(value)) {
471
528
  schema.properties[key] = { $ref: '#/$defs/damAssetRef' };
472
529
  } else {
473
- replaceGradialImagesWithDamAssetRef(value, damAssetRef);
530
+ replaceImagesWithDamAssetRef(value, damAssetRef);
474
531
  }
475
532
  }
476
533
  }
477
534
  if (schema.items) {
478
- if (isJsonGradialImage(schema.items)) {
535
+ if (isJsonImage(schema.items)) {
479
536
  schema.items = { $ref: '#/$defs/damAssetRef' };
480
537
  } else {
481
- replaceGradialImagesWithDamAssetRef(schema.items, damAssetRef);
538
+ replaceImagesWithDamAssetRef(schema.items, damAssetRef);
482
539
  }
483
540
  }
484
541
  }
485
542
 
486
- function isJsonGradialImage(node) {
543
+ function isJsonImage(node) {
487
544
  return node?.type === 'object'
488
545
  && node?.properties?.$type?.const === 'gradial.image';
489
546
  }
@@ -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) {
@@ -1,28 +1,47 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ // ---------------------------------------------------------------------------
4
+ // Image source variant (responsive derivative)
5
+ //
6
+ // Each entry is one concrete derivative produced by the compiler for the asset
7
+ // slot. Renderers pick the primary variant and build a srcset from the list.
8
+ // ---------------------------------------------------------------------------
9
+
3
10
  export interface ImageSource {
11
+ /** Derivative URL. */
4
12
  src: string;
13
+ /** Intrinsic width in pixels. */
5
14
  width: number;
15
+ /** Intrinsic height in pixels. */
6
16
  height: number;
17
+ /** MIME type hint, e.g. image/webp. */
7
18
  type: string;
8
19
  }
9
20
 
10
- export interface PictureSource {
11
- media?: string;
12
- type: string;
13
- srcset: string;
14
- sizes?: string;
15
- }
21
+ // ---------------------------------------------------------------------------
22
+ // Image — DAM-backed image asset with compiler-resolved derivatives
23
+ //
24
+ // Content authors write an asset reference: { "$type": "dam.assetRef", ... }
25
+ // The compiler resolves it to this Image value based on the asset's media type.
26
+ // ---------------------------------------------------------------------------
16
27
 
17
- export interface GradialImage {
28
+ export interface Image {
29
+ /** Discriminator — always 'gradial.image'. */
18
30
  $type: 'gradial.image';
31
+ /** DAM asset identifier. */
19
32
  assetId: string;
33
+ /** DAM version identifier. */
20
34
  versionId: string;
35
+ /** Accessible description of the image content. */
21
36
  alt: string;
22
- fallback: ImageSource;
23
- sources: PictureSource[] | null;
37
+ /** Responsive derivative variants produced for the asset slot. */
38
+ sources: ImageSource[];
24
39
  }
25
40
 
41
+ // ---------------------------------------------------------------------------
42
+ // Image slot contract (declared by components that accept image props)
43
+ // ---------------------------------------------------------------------------
44
+
26
45
  export interface ImageSlotContract {
27
46
  outputs: SlotOutput[];
28
47
  formats: string[];
@@ -35,66 +54,66 @@ export interface SlotOutput {
35
54
  media?: string;
36
55
  }
37
56
 
38
- export const GradialImageSchema = z.object({
57
+ // ---------------------------------------------------------------------------
58
+ // Zod schema
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export const ImageSourceSchema = z.object({
62
+ src: z.string().min(1),
63
+ width: z.number().int().nonnegative(),
64
+ height: z.number().int().nonnegative(),
65
+ type: z.string().min(1),
66
+ });
67
+
68
+ export const ImageSchema = z.object({
39
69
  $type: z.literal('gradial.image'),
40
70
  assetId: z.string().min(1),
41
71
  versionId: z.string().min(1),
42
72
  alt: z.string(),
43
- fallback: z.object({
44
- src: z.string().min(1),
45
- width: z.number().int().nonnegative(),
46
- height: z.number().int().nonnegative(),
47
- type: z.string().min(1),
48
- }),
49
- sources: z.array(z.object({
50
- media: z.string().optional(),
51
- type: z.string().min(1),
52
- srcset: z.string().min(1),
53
- sizes: z.string().optional(),
54
- })).nullable().default([]),
73
+ sources: z.array(ImageSourceSchema).min(1),
55
74
  });
56
75
 
57
- export type ImageHTMLAttributes = Record<string, string | number | boolean | undefined | null>;
76
+ // ---------------------------------------------------------------------------
77
+ // Shared attrs helper — consumed by React, Astro, and Svelte components
78
+ // ---------------------------------------------------------------------------
58
79
 
59
- export function renderImageHTML(image: GradialImage, attrs: ImageHTMLAttributes = {}): string {
60
- const imgAttrs = renderAttrs({
61
- ...attrs,
62
- src: image.fallback.src,
63
- alt: image.alt,
64
- width: image.fallback.width > 0 ? image.fallback.width : undefined,
65
- height: image.fallback.height > 0 ? image.fallback.height : undefined,
66
- });
67
- const img = `<img${imgAttrs}>`;
68
- if (!image.sources?.length) {
69
- return img;
70
- }
71
- const sources = image.sources.map((source) => `<source${renderAttrs({
72
- media: source.media,
73
- type: source.type,
74
- srcset: source.srcset,
75
- sizes: source.sizes,
76
- })}>`).join('');
77
- return `<picture>${sources}${img}</picture>`;
78
- }
80
+ export type ImageAttrs = Record<string, unknown>;
79
81
 
80
- function renderAttrs(attrs: ImageHTMLAttributes): string {
81
- const rendered = Object.entries(attrs)
82
- .filter(([, value]) => value !== undefined && value !== null && value !== false)
83
- .map(([name, value]) => value === true ? escapeName(name) : `${escapeName(name)}="${escapeAttr(String(value))}"`);
84
- if (!rendered.length) {
85
- return '';
86
- }
87
- return ' ' + rendered.join(' ');
82
+ /**
83
+ * Picks the primary variant from an image's sources.
84
+ * Uses the largest width; ties fall back to the first entry.
85
+ */
86
+ export function primaryImageSource(image: Image): ImageSource {
87
+ return image.sources.reduce((largest, source) =>
88
+ source.width > largest.width ? source : largest,
89
+ image.sources[0],
90
+ );
88
91
  }
89
92
 
90
- function escapeName(name: string): string {
91
- return name.replace(/[^A-Za-z0-9_:-]/g, '');
92
- }
93
+ /**
94
+ * Computes the full set of <img> attributes for an Image.
95
+ *
96
+ * Framework components (React <Picture>, Astro <img>, Svelte <img>) call this
97
+ * and spread the result into their native element. No HTML string serialization.
98
+ */
99
+ export function imageAttrs(image: Image, overrides: ImageAttrs = {}): ImageAttrs {
100
+ const primary = primaryImageSource(image);
101
+ const srcset = image.sources
102
+ .map((s) => `${s.src} ${s.width}w`)
103
+ .join(', ');
104
+ const sizes = image.sources
105
+ .map((s) => `(max-width: ${s.width}px) ${s.width}px`)
106
+ .join(', ');
93
107
 
94
- function escapeAttr(value: string): string {
95
- return value
96
- .replace(/&/g, '&amp;')
97
- .replace(/"/g, '&quot;')
98
- .replace(/</g, '&lt;')
99
- .replace(/>/g, '&gt;');
108
+ return {
109
+ src: primary.src,
110
+ alt: image.alt,
111
+ width: primary.width > 0 ? primary.width : undefined,
112
+ height: primary.height > 0 ? primary.height : undefined,
113
+ srcSet: srcset || undefined,
114
+ sizes: sizes || undefined,
115
+ loading: 'lazy',
116
+ decoding: 'async',
117
+ ...overrides,
118
+ };
100
119
  }