@gradial/aci 0.1.41 → 0.1.42

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 (102) hide show
  1. package/CHANGELOG.md +9 -13
  2. package/README.md +4 -4
  3. package/dist/astro/index.d.ts +1 -1
  4. package/dist/astro/index.js +1 -1
  5. package/dist/astro/page.d.ts +6 -0
  6. package/dist/astro/page.js +3 -2
  7. package/dist/cli/compile-registry.d.mts +2 -0
  8. package/dist/cli/compile-registry.mjs +759 -0
  9. package/dist/cli/materialize-template.d.mts +19 -0
  10. package/dist/cli/materialize-template.mjs +128 -0
  11. package/dist/cli/validate-content.d.mts +2 -0
  12. package/dist/cli/validate-content.mjs +426 -0
  13. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  14. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  15. package/dist/collect/assignments.d.ts +65 -0
  16. package/dist/collect/assignments.js +138 -0
  17. package/dist/collect/batcher.d.ts +21 -0
  18. package/dist/collect/batcher.js +10 -0
  19. package/dist/collect/declarative.d.ts +1 -1
  20. package/dist/collect/declarative.js +7 -1
  21. package/dist/collect/index.js +30 -4
  22. package/dist/collect/pageviews.d.ts +15 -3
  23. package/dist/collect/pageviews.js +28 -13
  24. package/dist/collect/transport.d.ts +2 -0
  25. package/dist/collect/transport.js +32 -0
  26. package/dist/consent-banner/index.d.ts +33 -0
  27. package/dist/consent-banner/index.js +99 -0
  28. package/dist/content/personalization.d.ts +35 -0
  29. package/dist/content/personalization.js +57 -0
  30. package/dist/content/types.d.ts +7 -1
  31. package/dist/edge/personalization-core.d.ts +11 -0
  32. package/dist/edge/personalization-core.js +265 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +1 -0
  35. package/dist/next/assignments-script.d.ts +14 -0
  36. package/dist/next/assignments-script.js +17 -0
  37. package/dist/next/collect-route.d.ts +19 -0
  38. package/dist/next/collect-route.js +351 -0
  39. package/dist/next/index.d.ts +1 -0
  40. package/dist/next/index.js +1 -0
  41. package/dist/next/middleware.d.ts +15 -1
  42. package/dist/next/middleware.js +71 -27
  43. package/dist/next/page.js +2 -1
  44. package/dist/next/personalization-host.d.ts +118 -0
  45. package/dist/next/personalization-host.js +276 -0
  46. package/dist/next/server.js +6 -6
  47. package/dist/next/vercel-personalization.d.ts +3 -0
  48. package/dist/next/vercel-personalization.js +3 -0
  49. package/dist/next/vercel-s3-personalization.d.ts +58 -0
  50. package/dist/next/vercel-s3-personalization.js +220 -0
  51. package/dist/providers/s3.d.ts +1 -1
  52. package/dist/providers/s3.js +1 -1
  53. package/dist/react/VideoPlayer.d.ts +6 -6
  54. package/dist/react/index.d.ts +6 -6
  55. package/dist/types/page.d.ts +1 -1
  56. package/package.json +19 -1
  57. package/src/astro/ConsentBanner.astro +62 -0
  58. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  59. package/src/consent-banner/styles.css +110 -0
  60. package/src/types/page.ts +1 -1
  61. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  62. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  63. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  64. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  65. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  66. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  67. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  68. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  69. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  70. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  71. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  72. package/templates/common/.content/pages/home/_index.json +114 -4
  73. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  74. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  75. package/templates/common/contracts/components/index.ts +21 -1
  76. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  77. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  78. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  79. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  80. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  81. package/templates/nextjs/starter-content/src/middleware.ts +11 -0
  82. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  83. package/templates/nextjs/template/src/middleware.ts +6 -2
  84. package/templates/astro/template/.aci.yaml +0 -28
  85. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  86. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  87. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  88. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  89. package/templates/common/.content/config/robots.txt +0 -2
  90. package/templates/common/.content/config/site.json +0 -197
  91. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  92. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  93. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  94. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  95. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  96. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  97. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  98. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  99. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  100. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  101. package/templates/common/.content/pages/about/_index.json +0 -51
  102. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Assemble a complete site tree from the SDK templates.
3
+ *
4
+ * @param {object} opts
5
+ * @param {string} opts.templatesDir absolute path to the templates root
6
+ * @param {string} opts.framework 'nextjs' | 'astro'
7
+ * @param {string} opts.destDir absolute destination (created if missing)
8
+ * @param {{siteName: string, version: string, aciVersion: string}} opts.vars
9
+ */
10
+ export function materializeTemplate({ templatesDir, framework, destDir, vars }: {
11
+ templatesDir: string;
12
+ framework: string;
13
+ destDir: string;
14
+ vars: {
15
+ siteName: string;
16
+ version: string;
17
+ aciVersion: string;
18
+ };
19
+ }): void;
@@ -0,0 +1,128 @@
1
+ // TS reference engine for scaffolding a site from the SDK templates.
2
+ //
3
+ // `scripts/generate-starters.ts` (repo codegen for starters/*) calls
4
+ // materializeTemplate, then applies the starter-content overlay as an
5
+ // explicit stage on top. Customer scaffolding (`aci init`) is the Go CLI
6
+ // (internal/transport/cli/cmd/init.go), which implements the same engine
7
+ // semantics; scripts/test-starter-dev.mjs asserts the two engines produce
8
+ // byte-identical output so they can never drift.
9
+ // Spec: specs/features/starter-fresh-clone-dx (D-1).
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ const EXCLUDE_PATTERNS = [
13
+ /node_modules/,
14
+ /\.DS_Store$/,
15
+ /package-lock\.json$/,
16
+ /\.git$/,
17
+ /storybook-static/,
18
+ /starter-content/,
19
+ ];
20
+ const COMMON_DIRS = ['.aci', '.agents', '.content', 'eslint-rules'];
21
+ const COMMON_ROOT_FILES = ['.nvmrc'];
22
+ // Per-framework values rendered into shared templates (e.g. README.md.tmpl).
23
+ // Derived here so `aci init` and `generate-starters` can never disagree.
24
+ const FRAMEWORK_VARS = {
25
+ nextjs: { devUrl: 'http://localhost:3000', frameworkName: 'Next.js' },
26
+ astro: { devUrl: 'http://localhost:4321', frameworkName: 'Astro' },
27
+ };
28
+ // Exclusions match paths RELATIVE to the copy root: the SDK itself is often
29
+ // installed under a node_modules/ directory (npm install, the npx cache), so
30
+ // matching absolute paths would exclude every template file.
31
+ function shouldExclude(relPath) {
32
+ return EXCLUDE_PATTERNS.some((pattern) => pattern.test(relPath));
33
+ }
34
+ function copyDirSync(src, dest, root = src) {
35
+ if (!fs.existsSync(src))
36
+ return;
37
+ const entries = fs.readdirSync(src, { withFileTypes: true });
38
+ for (const entry of entries) {
39
+ const srcPath = path.join(src, entry.name);
40
+ const destPath = path.join(dest, entry.name);
41
+ if (shouldExclude(path.relative(root, srcPath)))
42
+ continue;
43
+ if (entry.isDirectory()) {
44
+ fs.mkdirSync(destPath, { recursive: true });
45
+ copyDirSync(srcPath, destPath, root);
46
+ }
47
+ else {
48
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
49
+ fs.copyFileSync(srcPath, destPath);
50
+ }
51
+ }
52
+ }
53
+ function renderTmplFiles(dir, vars, root = dir) {
54
+ if (!fs.existsSync(dir))
55
+ return;
56
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ const fullPath = path.join(dir, entry.name);
59
+ if (shouldExclude(path.relative(root, fullPath)))
60
+ continue;
61
+ if (entry.isDirectory()) {
62
+ renderTmplFiles(fullPath, vars, root);
63
+ }
64
+ else if (entry.name.endsWith('.tmpl')) {
65
+ let content = fs.readFileSync(fullPath, 'utf8');
66
+ for (const [key, value] of Object.entries(vars)) {
67
+ // Function replacement so `$` sequences in values (site names are
68
+ // user-controlled) are inserted literally, not as $-patterns.
69
+ content = content.replaceAll(`{{${key}}}`, () => value);
70
+ }
71
+ fs.writeFileSync(fullPath.replace(/\.tmpl$/, ''), content);
72
+ fs.unlinkSync(fullPath);
73
+ }
74
+ }
75
+ }
76
+ /**
77
+ * Assemble a complete site tree from the SDK templates.
78
+ *
79
+ * @param {object} opts
80
+ * @param {string} opts.templatesDir absolute path to the templates root
81
+ * @param {string} opts.framework 'nextjs' | 'astro'
82
+ * @param {string} opts.destDir absolute destination (created if missing)
83
+ * @param {{siteName: string, version: string, aciVersion: string}} opts.vars
84
+ */
85
+ export function materializeTemplate({ templatesDir, framework, destDir, vars }) {
86
+ const commonDir = path.join(templatesDir, 'common');
87
+ const templateDir = path.join(templatesDir, framework, 'template');
88
+ if (!fs.existsSync(templateDir)) {
89
+ throw new Error(`unknown framework template: ${templateDir}`);
90
+ }
91
+ for (const key of ['siteName', 'version', 'aciVersion']) {
92
+ if (!vars[key])
93
+ throw new Error(`materializeTemplate: vars.${key} is required`);
94
+ }
95
+ fs.mkdirSync(destDir, { recursive: true });
96
+ for (const dir of COMMON_DIRS) {
97
+ copyDirSync(path.join(commonDir, dir), path.join(destDir, dir));
98
+ }
99
+ for (const file of COMMON_ROOT_FILES) {
100
+ const src = path.join(commonDir, file);
101
+ if (fs.existsSync(src))
102
+ fs.copyFileSync(src, path.join(destDir, file));
103
+ }
104
+ copyDirSync(path.join(commonDir, 'public', 'assets'), path.join(destDir, 'public', 'assets'));
105
+ const readmeTmpl = path.join(commonDir, 'README.md.tmpl');
106
+ if (fs.existsSync(readmeTmpl)) {
107
+ fs.copyFileSync(readmeTmpl, path.join(destDir, 'README.md.tmpl'));
108
+ }
109
+ copyDirSync(templateDir, destDir);
110
+ // Shared design-system sources overwrite any framework-template copies so
111
+ // design tokens can only come from templates/common. Contract sources are
112
+ // NOT copied here: a scaffold ships only the empty contracts barrel from the
113
+ // framework template; populated contracts arrive via the starter-content
114
+ // overlay stage (generate-starters.ts / `aci init --with-starter-content`).
115
+ copyDirSync(path.join(commonDir, 'design-system', 'tokens'), path.join(destDir, 'src', 'design-system', 'tokens'));
116
+ const stylesSrc = path.join(commonDir, 'design-system', 'styles.css');
117
+ if (fs.existsSync(stylesSrc)) {
118
+ fs.mkdirSync(path.join(destDir, 'src', 'design-system'), { recursive: true });
119
+ fs.copyFileSync(stylesSrc, path.join(destDir, 'src', 'design-system', 'styles.css'));
120
+ }
121
+ // npm pack drops `.gitignore` files from the published templates, so the
122
+ // framework templates ship `gitignore` and it is renamed on materialize.
123
+ const gitignore = path.join(destDir, 'gitignore');
124
+ if (fs.existsSync(gitignore)) {
125
+ fs.renameSync(gitignore, path.join(destDir, '.gitignore'));
126
+ }
127
+ renderTmplFiles(destDir, { ...FRAMEWORK_VARS[framework], ...vars });
128
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * validate-content — validate authored page content against compiled JSON schemas.
4
+ *
5
+ * Usage:
6
+ * node validate-content.mjs \
7
+ * --registry <path-to-compiled-registry-dir> \
8
+ * --compiled <path-to-compiled-content-dir>
9
+ *
10
+ * Prerequisites:
11
+ * 1. Run compile-registry to generate registry.json + schemas/*.schema.json
12
+ * 2. Run content build to generate compiled routes + fragments
13
+ *
14
+ * Validates every block in the COMPILED output against JSON schemas.
15
+ * This validates the build result — after DAM resolution, fragment inlining, etc.
16
+ * Fails with exit code 1 if any published page has validation errors.
17
+ * Warns (but does not fail) for draft pages.
18
+ *
19
+ * Uses the JSON schemas generated by compile-registry — portable, no React/CSS
20
+ * imports needed, same schemas usable by Go backend or other tools.
21
+ */
22
+ import { readdir, readFile } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ const args = parseArgs(process.argv.slice(2));
25
+ try {
26
+ const result = await validateContent(args);
27
+ console.log(JSON.stringify(result, null, 2));
28
+ if (!result.success)
29
+ process.exitCode = 1;
30
+ }
31
+ catch (error) {
32
+ console.error(JSON.stringify({
33
+ success: false,
34
+ errors: [{ message: String(error?.message ?? error) }],
35
+ }, null, 2));
36
+ process.exitCode = 1;
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Main
40
+ // ---------------------------------------------------------------------------
41
+ async function validateContent(options) {
42
+ if (!options.registry)
43
+ throw new Error('missing --registry (path to compiled registry directory)');
44
+ if (!options.compiled)
45
+ throw new Error('missing --compiled (path to compiled content output directory)');
46
+ const registryDir = path.resolve(options.registry);
47
+ const contentRoot = path.resolve(options.compiled);
48
+ // Load compiled registry
49
+ const registryPath = path.join(registryDir, 'registry.json');
50
+ const registryRaw = await readFile(registryPath, 'utf-8');
51
+ const registry = JSON.parse(registryRaw);
52
+ // Build schema map: component name → JSON Schema object
53
+ const schemaMap = new Map();
54
+ const knownComponents = new Set();
55
+ const componentMap = new Map();
56
+ for (const component of registry.components) {
57
+ knownComponents.add(component.name);
58
+ componentMap.set(component.name, component);
59
+ if (component.props) {
60
+ const schemaPath = path.join(registryDir, component.props);
61
+ const schemaRaw = await readFile(schemaPath, 'utf-8');
62
+ schemaMap.set(component.name, JSON.parse(schemaRaw));
63
+ }
64
+ }
65
+ // Find all compiled route files
66
+ const routesDir = path.join(contentRoot, 'routes');
67
+ const pageFiles = await findJsonFiles(routesDir);
68
+ const errors = [];
69
+ const warnings = [];
70
+ let pagesValidated = 0;
71
+ let blocksValidated = 0;
72
+ for (const pageFile of pageFiles) {
73
+ const relativePath = path.relative(contentRoot, pageFile);
74
+ let pageData;
75
+ try {
76
+ const raw = await readFile(pageFile, 'utf-8');
77
+ pageData = JSON.parse(raw);
78
+ }
79
+ catch (err) {
80
+ errors.push({ page: relativePath, message: `failed to parse JSON: ${err.message}` });
81
+ continue;
82
+ }
83
+ pagesValidated++;
84
+ const isPublished = pageData.status === 'published';
85
+ const collector = isPublished ? errors : warnings;
86
+ const blocks = collectBlocks(pageData);
87
+ for (const block of blocks) {
88
+ blocksValidated++;
89
+ if (!block.component) {
90
+ collector.push({
91
+ page: relativePath,
92
+ blockId: block.id ?? '<unknown>',
93
+ message: 'block is missing "component" field',
94
+ });
95
+ continue;
96
+ }
97
+ // Check component exists in registry
98
+ if (!knownComponents.has(block.component)) {
99
+ collector.push({
100
+ page: relativePath,
101
+ blockId: block.id ?? '<unknown>',
102
+ component: block.component,
103
+ message: `unknown component: "${block.component}"`,
104
+ });
105
+ continue;
106
+ }
107
+ // Validate props against JSON Schema
108
+ const schema = schemaMap.get(block.component);
109
+ if (!schema)
110
+ continue; // no schema file — component registered but no schema to check
111
+ const props = block.props ?? {};
112
+ const issues = validateJsonSchema(props, schema, 'props');
113
+ for (const issue of issues) {
114
+ collector.push({
115
+ page: relativePath,
116
+ blockId: block.id ?? '<unknown>',
117
+ component: block.component,
118
+ path: issue.path,
119
+ message: issue.message,
120
+ });
121
+ }
122
+ const nestedIssues = validateNestedSlots(block, componentMap, schemaMap);
123
+ for (const issue of nestedIssues) {
124
+ collector.push({
125
+ page: relativePath,
126
+ blockId: block.id ?? '<unknown>',
127
+ component: block.component,
128
+ ...issue,
129
+ });
130
+ }
131
+ }
132
+ }
133
+ // Also validate fragments
134
+ const fragmentsDir = path.join(contentRoot, 'fragments');
135
+ const fragmentFiles = await findJsonFiles(fragmentsDir);
136
+ for (const fragmentFile of fragmentFiles) {
137
+ const relativePath = path.relative(contentRoot, fragmentFile);
138
+ let fragmentData;
139
+ try {
140
+ const raw = await readFile(fragmentFile, 'utf-8');
141
+ fragmentData = JSON.parse(raw);
142
+ }
143
+ catch (err) {
144
+ errors.push({ fragment: relativePath, message: `failed to parse JSON: ${err.message}` });
145
+ continue;
146
+ }
147
+ if (!fragmentData.component)
148
+ continue;
149
+ blocksValidated++;
150
+ if (!knownComponents.has(fragmentData.component)) {
151
+ errors.push({
152
+ fragment: relativePath,
153
+ component: fragmentData.component,
154
+ message: `unknown component: "${fragmentData.component}"`,
155
+ });
156
+ continue;
157
+ }
158
+ const schema = schemaMap.get(fragmentData.component);
159
+ if (!schema)
160
+ continue;
161
+ const props = fragmentData.props ?? {};
162
+ const issues = validateJsonSchema(props, schema, 'props');
163
+ for (const issue of issues) {
164
+ errors.push({
165
+ fragment: relativePath,
166
+ component: fragmentData.component,
167
+ path: issue.path,
168
+ message: issue.message,
169
+ });
170
+ }
171
+ }
172
+ const success = errors.length === 0;
173
+ return {
174
+ success,
175
+ pagesValidated,
176
+ blocksValidated,
177
+ errors,
178
+ ...(warnings.length > 0 ? { warnings } : {}),
179
+ };
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Lightweight JSON Schema draft-2020-12 validator
183
+ //
184
+ // Covers the subset that Zod generates via z.toJSONSchema:
185
+ // type, required, properties, additionalProperties,
186
+ // minLength, minimum, maximum, enum, const,
187
+ // items, minItems, maxItems, anyOf, allOf, $ref (local)
188
+ // ---------------------------------------------------------------------------
189
+ function validateJsonSchema(value, schema, pathPrefix = '') {
190
+ const issues = [];
191
+ _validate(value, schema, pathPrefix, issues, schema);
192
+ return issues;
193
+ }
194
+ function _validate(value, schema, currentPath, issues, rootSchema) {
195
+ if (!schema || typeof schema !== 'object')
196
+ return;
197
+ // $ref — resolve local references
198
+ if (schema.$ref) {
199
+ const resolved = resolveRef(schema.$ref, rootSchema);
200
+ if (resolved) {
201
+ _validate(value, resolved, currentPath, issues, rootSchema);
202
+ }
203
+ return;
204
+ }
205
+ // anyOf — value must match at least one
206
+ if (Array.isArray(schema.anyOf)) {
207
+ const anyMatches = schema.anyOf.some((sub) => {
208
+ const subIssues = [];
209
+ _validate(value, sub, currentPath, subIssues, rootSchema);
210
+ return subIssues.length === 0;
211
+ });
212
+ if (!anyMatches) {
213
+ issues.push({ path: currentPath, message: `${currentPath}: does not match any allowed schema` });
214
+ }
215
+ return;
216
+ }
217
+ // allOf — value must match all
218
+ if (Array.isArray(schema.allOf)) {
219
+ for (const sub of schema.allOf) {
220
+ _validate(value, sub, currentPath, issues, rootSchema);
221
+ }
222
+ return;
223
+ }
224
+ // const
225
+ if ('const' in schema) {
226
+ if (value !== schema.const) {
227
+ issues.push({ path: currentPath, message: `${currentPath}: expected constant "${schema.const}", got ${JSON.stringify(value)}` });
228
+ }
229
+ return;
230
+ }
231
+ // enum
232
+ if (Array.isArray(schema.enum)) {
233
+ if (!schema.enum.includes(value)) {
234
+ issues.push({ path: currentPath, message: `${currentPath}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}` });
235
+ }
236
+ return;
237
+ }
238
+ // type checking
239
+ const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null;
240
+ if (types) {
241
+ const actualType = getJsonType(value);
242
+ const typeMatch = types.some((t) => t === actualType || (t === 'integer' && actualType === 'number' && Number.isInteger(value)));
243
+ if (!typeMatch) {
244
+ // Allow null for nullable schemas
245
+ if (value !== null || !types.includes('null')) {
246
+ issues.push({ path: currentPath, message: `${currentPath}: expected type ${types.join('|')}, got ${actualType}` });
247
+ return; // Stop further checks if type is wrong
248
+ }
249
+ }
250
+ }
251
+ if (value === null || value === undefined)
252
+ return;
253
+ // String checks
254
+ if (typeof value === 'string') {
255
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
256
+ issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} is less than minLength ${schema.minLength}` });
257
+ }
258
+ if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
259
+ issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} exceeds maxLength ${schema.maxLength}` });
260
+ }
261
+ }
262
+ // Number checks
263
+ if (typeof value === 'number') {
264
+ if (typeof schema.minimum === 'number' && value < schema.minimum) {
265
+ issues.push({ path: currentPath, message: `${currentPath}: ${value} is less than minimum ${schema.minimum}` });
266
+ }
267
+ if (typeof schema.maximum === 'number' && value > schema.maximum) {
268
+ issues.push({ path: currentPath, message: `${currentPath}: ${value} exceeds maximum ${schema.maximum}` });
269
+ }
270
+ }
271
+ // Array checks
272
+ if (Array.isArray(value)) {
273
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
274
+ issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, minimum is ${schema.minItems}` });
275
+ }
276
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
277
+ issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, maximum is ${schema.maxItems}` });
278
+ }
279
+ if (schema.items) {
280
+ for (let i = 0; i < value.length; i++) {
281
+ _validate(value[i], schema.items, `${currentPath}[${i}]`, issues, rootSchema);
282
+ }
283
+ }
284
+ }
285
+ // Object checks
286
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
287
+ // Required fields
288
+ if (Array.isArray(schema.required)) {
289
+ for (const key of schema.required) {
290
+ if (!(key in value) || value[key] === undefined) {
291
+ issues.push({ path: `${currentPath}.${key}`, message: `${currentPath}.${key}: required field is missing` });
292
+ }
293
+ }
294
+ }
295
+ // Properties
296
+ if (schema.properties) {
297
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
298
+ if (key in value) {
299
+ _validate(value[key], propSchema, `${currentPath}.${key}`, issues, rootSchema);
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ function getJsonType(value) {
306
+ if (value === null)
307
+ return 'null';
308
+ if (Array.isArray(value))
309
+ return 'array';
310
+ return typeof value;
311
+ }
312
+ function resolveRef(ref, rootSchema) {
313
+ if (!ref.startsWith('#/'))
314
+ return null;
315
+ const parts = ref.slice(2).split('/');
316
+ let current = rootSchema;
317
+ for (const part of parts) {
318
+ current = current?.[part];
319
+ }
320
+ return current ?? null;
321
+ }
322
+ // ---------------------------------------------------------------------------
323
+ // Content helpers
324
+ // ---------------------------------------------------------------------------
325
+ // ---------------------------------------------------------------------------
326
+ // Block ref validation — recursively validates nested blocks inside props.
327
+ // ---------------------------------------------------------------------------
328
+ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props') {
329
+ const issues = [];
330
+ const parentComponent = componentMap.get(parentBlock.component);
331
+ if (!parentComponent)
332
+ return issues;
333
+ const blockSlots = parentComponent.slots ?? {};
334
+ for (const [slotName, slot] of Object.entries(blockSlots)) {
335
+ const blocks = parentBlock.props?.[slotName];
336
+ if (!Array.isArray(blocks)) {
337
+ if (slot.required) {
338
+ issues.push({ path: `${path}.${slotName}`, message: `${parentBlock.component}: required block ref "${slotName}" has no children` });
339
+ }
340
+ continue;
341
+ }
342
+ if (!slot.multiple && blocks.length > 1) {
343
+ issues.push({ path: `${path}.${slotName}`, message: `${parentBlock.component}: block ref "${slotName}" allows at most one child` });
344
+ }
345
+ for (const [index, nestedBlock] of blocks.entries()) {
346
+ const nestedPath = `${path}.${slotName}[${index}]`;
347
+ if (!nestedBlock || typeof nestedBlock !== 'object' || typeof nestedBlock.component !== 'string') {
348
+ issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
349
+ continue;
350
+ }
351
+ if (!componentMap.has(nestedBlock.component)) {
352
+ issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
353
+ continue;
354
+ }
355
+ if (!slot.allowed.includes(nestedBlock.component)) {
356
+ issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: component "${nestedBlock.component}" is not allowed in block ref "${slotName}"` });
357
+ }
358
+ const childSchema = schemaMap.get(nestedBlock.component);
359
+ const childProps = nestedBlock.props ?? {};
360
+ if (childSchema) {
361
+ for (const issue of validateJsonSchema(childProps, childSchema, `${nestedPath}.props`)) {
362
+ issues.push({ nestedComponent: nestedBlock.component, path: issue.path, message: `${parentBlock.component} → ${nestedBlock.component}: ${issue.message}` });
363
+ }
364
+ }
365
+ issues.push(...validateNestedSlots(nestedBlock, componentMap, schemaMap, nestedPath));
366
+ }
367
+ }
368
+ return issues;
369
+ }
370
+ function collectBlocks(pageData) {
371
+ const blocks = [];
372
+ if (pageData.regions && typeof pageData.regions === 'object') {
373
+ for (const region of Object.values(pageData.regions)) {
374
+ if (Array.isArray(region))
375
+ blocks.push(...region);
376
+ else if (region && typeof region === 'object' && Array.isArray(region.children))
377
+ blocks.push(...region.children);
378
+ }
379
+ }
380
+ if (Array.isArray(pageData.blocks))
381
+ blocks.push(...pageData.blocks);
382
+ return blocks;
383
+ }
384
+ async function findPageFiles(dir) {
385
+ return findFilesMatching(dir, (name) => name === '_index.json');
386
+ }
387
+ async function findJsonFiles(dir) {
388
+ return findFilesMatching(dir, (name) => name.endsWith('.json'));
389
+ }
390
+ async function findFilesMatching(dir, predicate) {
391
+ const results = [];
392
+ try {
393
+ await collectFiles(dir, predicate, results);
394
+ }
395
+ catch (err) {
396
+ if (err.code !== 'ENOENT')
397
+ throw err;
398
+ }
399
+ return results;
400
+ }
401
+ async function collectFiles(dir, predicate, results) {
402
+ const entries = await readdir(dir, { withFileTypes: true });
403
+ for (const entry of entries) {
404
+ const full = path.join(dir, entry.name);
405
+ if (entry.isDirectory())
406
+ await collectFiles(full, predicate, results);
407
+ else if (predicate(entry.name))
408
+ results.push(full);
409
+ }
410
+ }
411
+ function parseArgs(argv) {
412
+ const out = {};
413
+ for (let i = 0; i < argv.length; i++) {
414
+ const arg = argv[i];
415
+ if (!arg.startsWith('--'))
416
+ continue;
417
+ const body = arg.slice(2);
418
+ const equals = body.indexOf('=');
419
+ if (equals >= 0) {
420
+ out[body.slice(0, equals)] = body.slice(equals + 1);
421
+ continue;
422
+ }
423
+ out[body] = argv[++i];
424
+ }
425
+ return out;
426
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ const projectRoot = process.argv[2] || '.';
5
+ const checks = [];
6
+ const missing = [];
7
+ function checkFile(label, relativePath, validate, message) {
8
+ const fullPath = path.join(projectRoot, relativePath);
9
+ if (!fs.existsSync(fullPath)) {
10
+ missing.push(`✗ ${label}: ${relativePath} not found`);
11
+ return;
12
+ }
13
+ const content = fs.readFileSync(fullPath, 'utf-8');
14
+ if (!validate(content)) {
15
+ checks.push(`✗ ${label}: ${message}`);
16
+ return;
17
+ }
18
+ checks.push(`✓ ${label}`);
19
+ }
20
+ function checkMissing(label, relativePath, message) {
21
+ const fullPath = path.join(projectRoot, relativePath);
22
+ if (fs.existsSync(fullPath)) {
23
+ checks.push(`✗ ${label}: ${relativePath} should be removed. ${message}`);
24
+ return;
25
+ }
26
+ checks.push(`✓ ${label}`);
27
+ }
28
+ checkFile('middleware is configured', 'src/middleware.ts', (content) => (content.includes('@gradial/aci/next/middleware') || content.includes('createGradialMiddleware'))
29
+ && !content.includes('|__r'), 'Expected src/middleware.ts to use @gradial/aci/next/middleware and not exclude __r; middleware blocks direct /__r requests.');
30
+ checkFile('catch-all page route exists', 'src/app/[[...slug]]/page.tsx', (content) => content.includes('createPage') || content.includes('getRenderInput'), 'Expected catch-all page to use createPage() from the SDK.');
31
+ checkMissing('legacy required catch-all is removed', 'src/app/[...slug]/page.tsx', 'Use src/app/[[...slug]]/page.tsx (optional catch-all). The required [...slug] form breaks file-provider local dev where "/" arrives with an empty slug.');
32
+ checkFile('canonical asset route exists', 'src/app/gradial/assets/[...path]/route.ts', (content) => content.includes('@gradial/aci/next/asset-route'), 'Expected the canonical asset route to use @gradial/aci/next/asset-route.');
33
+ checkMissing('legacy release-scoped asset route is removed', 'src/app/gradial/assets/[releaseId]/[...path]/route.ts', 'Release-scoped asset URLs are produced by withAci() rewrites into the canonical src/app/gradial/assets/[...path]/route.ts; do not add a [releaseId] route.');
34
+ checkMissing('no separate __r route folder', 'src/app/__r/[releaseId]/routes/[[...slug]]/page.tsx', 'The root catch-all handles release-scoped rewrites. Do not create a separate __r route folder.');
35
+ checkFile('next.config uses withAci', 'next.config.mjs', (content) => content.includes('withAci'), 'Expected next.config.mjs to use withAci() from @gradial/aci/next.');
36
+ for (const line of checks) {
37
+ console.log(line);
38
+ }
39
+ for (const line of missing) {
40
+ console.log(line);
41
+ }
42
+ const failed = [...checks.filter((line) => line.startsWith('✗')), ...missing];
43
+ if (failed.length > 0) {
44
+ console.error(`\n${failed.length} check(s) failed`);
45
+ process.exit(1);
46
+ }
47
+ console.log('\nAll route checks passed');