@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.
- package/CHANGELOG.md +9 -13
- package/README.md +4 -4
- package/dist/astro/index.d.ts +1 -1
- package/dist/astro/index.js +1 -1
- package/dist/astro/page.d.ts +6 -0
- package/dist/astro/page.js +3 -2
- package/dist/cli/compile-registry.d.mts +2 -0
- package/dist/cli/compile-registry.mjs +759 -0
- package/dist/cli/materialize-template.d.mts +19 -0
- package/dist/cli/materialize-template.mjs +128 -0
- package/dist/cli/validate-content.d.mts +2 -0
- package/dist/cli/validate-content.mjs +426 -0
- package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
- package/dist/cli/verify-next-cdn-routes.mjs +47 -0
- package/dist/collect/assignments.d.ts +65 -0
- package/dist/collect/assignments.js +138 -0
- package/dist/collect/batcher.d.ts +21 -0
- package/dist/collect/batcher.js +10 -0
- package/dist/collect/declarative.d.ts +1 -1
- package/dist/collect/declarative.js +7 -1
- package/dist/collect/index.js +30 -4
- package/dist/collect/pageviews.d.ts +15 -3
- package/dist/collect/pageviews.js +28 -13
- package/dist/collect/transport.d.ts +2 -0
- package/dist/collect/transport.js +32 -0
- package/dist/consent-banner/index.d.ts +33 -0
- package/dist/consent-banner/index.js +99 -0
- package/dist/content/personalization.d.ts +35 -0
- package/dist/content/personalization.js +57 -0
- package/dist/content/types.d.ts +7 -1
- package/dist/edge/personalization-core.d.ts +11 -0
- package/dist/edge/personalization-core.js +265 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/next/assignments-script.d.ts +14 -0
- package/dist/next/assignments-script.js +17 -0
- package/dist/next/collect-route.d.ts +19 -0
- package/dist/next/collect-route.js +351 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/middleware.d.ts +15 -1
- package/dist/next/middleware.js +71 -27
- package/dist/next/page.js +2 -1
- package/dist/next/personalization-host.d.ts +118 -0
- package/dist/next/personalization-host.js +276 -0
- package/dist/next/server.js +6 -6
- package/dist/next/vercel-personalization.d.ts +3 -0
- package/dist/next/vercel-personalization.js +3 -0
- package/dist/next/vercel-s3-personalization.d.ts +58 -0
- package/dist/next/vercel-s3-personalization.js +220 -0
- package/dist/providers/s3.d.ts +1 -1
- package/dist/providers/s3.js +1 -1
- package/dist/react/VideoPlayer.d.ts +6 -6
- package/dist/react/index.d.ts +6 -6
- package/dist/types/page.d.ts +1 -1
- package/package.json +19 -1
- package/src/astro/ConsentBanner.astro +62 -0
- package/src/cli/verify-next-cdn-routes.mjs +5 -4
- package/src/consent-banner/styles.css +110 -0
- package/src/types/page.ts +1 -1
- package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
- package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/astro/starter-content/src/cms/registry.ts +3 -0
- package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
- package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
- package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
- package/templates/astro/template/src/pages/[...slug].astro +1 -0
- package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
- package/templates/common/.content/audiences/preference-red/_index.json +11 -0
- package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
- package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
- package/templates/common/.content/pages/home/_index.json +114 -4
- package/templates/common/.content/segments/preference-blue/_index.json +20 -0
- package/templates/common/.content/segments/preference-red/_index.json +20 -0
- package/templates/common/contracts/components/index.ts +21 -1
- package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
- package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
- package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
- package/templates/nextjs/starter-content/src/middleware.ts +11 -0
- package/templates/nextjs/template/src/app/collect/route.ts +18 -0
- package/templates/nextjs/template/src/middleware.ts +6 -2
- package/templates/astro/template/.aci.yaml +0 -28
- package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
- package/templates/common/.content/config/robots.txt +0 -2
- package/templates/common/.content/config/site.json +0 -197
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
- package/templates/common/.content/fragments/navbar/_index.json +0 -25
- package/templates/common/.content/fragments/site-footer/_index.json +0 -62
- package/templates/common/.content/pages/about/_index.json +0 -51
- 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,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,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');
|