@gradial/aci 0.1.41 → 0.1.43

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 (134) hide show
  1. package/CHANGELOG.md +15 -11
  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/audience-data-models.d.ts +286 -0
  8. package/dist/audience-data-models.js +46 -0
  9. package/dist/cli/compile-registry.d.mts +6 -0
  10. package/dist/cli/compile-registry.mjs +774 -0
  11. package/dist/cli/materialize-template.d.mts +19 -0
  12. package/dist/cli/materialize-template.mjs +128 -0
  13. package/dist/cli/validate-content.d.mts +32 -0
  14. package/dist/cli/validate-content.mjs +464 -0
  15. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  16. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  17. package/dist/collect/aci.js +1 -0
  18. package/dist/collect/aci.js.sha256 +1 -0
  19. package/dist/collect/assignments.d.ts +65 -0
  20. package/dist/collect/assignments.js +138 -0
  21. package/dist/collect/batcher.d.ts +23 -0
  22. package/dist/collect/batcher.js +28 -6
  23. package/dist/collect/consent/envelope.d.ts +9 -3
  24. package/dist/collect/consent/envelope.js +58 -40
  25. package/dist/collect/declarative.d.ts +1 -1
  26. package/dist/collect/declarative.js +7 -1
  27. package/dist/collect/index.js +39 -6
  28. package/dist/collect/linkclicks.js +4 -2
  29. package/dist/collect/pageviews.d.ts +15 -3
  30. package/dist/collect/pageviews.js +28 -13
  31. package/dist/collect/transport.d.ts +4 -0
  32. package/dist/collect/transport.js +37 -2
  33. package/dist/consent/portable-envelope.d.ts +9 -0
  34. package/dist/consent/portable-envelope.js +110 -0
  35. package/dist/consent-banner/index.d.ts +33 -0
  36. package/dist/consent-banner/index.js +100 -0
  37. package/dist/content/assets.d.ts +1 -0
  38. package/dist/content/assets.js +1 -0
  39. package/dist/content/personalization.d.ts +35 -0
  40. package/dist/content/personalization.js +57 -0
  41. package/dist/content/resolve-slots.js +1 -0
  42. package/dist/content/types.d.ts +9 -1
  43. package/dist/edge/personalization-core.d.ts +11 -0
  44. package/dist/edge/personalization-core.js +265 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +1 -0
  47. package/dist/next/assignments-script.d.ts +14 -0
  48. package/dist/next/assignments-script.js +17 -0
  49. package/dist/next/collect-route.d.ts +19 -0
  50. package/dist/next/collect-route.js +422 -0
  51. package/dist/next/envelope-digest.d.ts +21 -0
  52. package/dist/next/envelope-digest.js +58 -0
  53. package/dist/next/index.d.ts +1 -0
  54. package/dist/next/index.js +1 -0
  55. package/dist/next/middleware.d.ts +31 -3
  56. package/dist/next/middleware.js +254 -30
  57. package/dist/next/page.js +2 -1
  58. package/dist/next/personalization-host.d.ts +127 -0
  59. package/dist/next/personalization-host.js +257 -0
  60. package/dist/next/server.d.ts +1 -0
  61. package/dist/next/server.js +9 -6
  62. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  63. package/dist/next/vercel-collect-sdk-route.js +205 -0
  64. package/dist/next/vercel-personalization.d.ts +4 -0
  65. package/dist/next/vercel-personalization.js +4 -0
  66. package/dist/next/vercel-s3-personalization.d.ts +79 -0
  67. package/dist/next/vercel-s3-personalization.js +574 -0
  68. package/dist/providers/s3.d.ts +20 -5
  69. package/dist/providers/s3.js +93 -23
  70. package/dist/react/VideoPlayer.d.ts +6 -6
  71. package/dist/react/index.d.ts +6 -6
  72. package/dist/testing/conformance/checks.js +75 -4
  73. package/dist/types/block-ref.d.ts +2 -0
  74. package/dist/types/block-ref.js +1 -0
  75. package/dist/types/page.d.ts +1 -1
  76. package/package.json +44 -15
  77. package/src/astro/ConsentBanner.astro +62 -0
  78. package/src/cli/compile-registry.mjs +24 -10
  79. package/src/cli/validate-content.mjs +52 -13
  80. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  81. package/src/consent-banner/styles.css +110 -0
  82. package/src/types/block-ref.test.ts +45 -0
  83. package/src/types/block-ref.ts +2 -0
  84. package/src/types/page.ts +1 -1
  85. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  86. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  87. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  88. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  89. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  90. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  91. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  92. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  93. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  94. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  95. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  96. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  97. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  98. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  99. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  100. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  101. package/templates/common/.content/pages/home/_index.json +124 -4
  102. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  103. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  104. package/templates/common/contracts/components/index.ts +21 -1
  105. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  106. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  107. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  108. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  109. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  110. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  111. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  112. package/templates/nextjs/starter-content/src/middleware.ts +41 -0
  113. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  114. package/templates/nextjs/template/src/middleware.ts +36 -2
  115. package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
  116. package/templates/astro/template/.aci.yaml +0 -28
  117. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  118. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  119. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  120. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  121. package/templates/common/.content/config/robots.txt +0 -2
  122. package/templates/common/.content/config/site.json +0 -197
  123. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  124. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  125. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  126. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  127. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  128. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  129. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  130. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  131. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  132. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  133. package/templates/common/.content/pages/about/_index.json +0 -51
  134. 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,32 @@
1
+ #!/usr/bin/env node
2
+ export function validateContent(options: any): Promise<{
3
+ warnings?: any[] | undefined;
4
+ success: boolean;
5
+ pagesValidated: number;
6
+ blocksValidated: number;
7
+ errors: ({
8
+ page: string;
9
+ message: string;
10
+ fragment?: undefined;
11
+ component?: undefined;
12
+ path?: undefined;
13
+ } | {
14
+ fragment: string;
15
+ message: string;
16
+ page?: undefined;
17
+ component?: undefined;
18
+ path?: undefined;
19
+ } | {
20
+ fragment: string;
21
+ component: any;
22
+ message: string;
23
+ page?: undefined;
24
+ path?: undefined;
25
+ } | {
26
+ fragment: string;
27
+ component: any;
28
+ path: any;
29
+ message: any;
30
+ page?: undefined;
31
+ })[];
32
+ }>;
@@ -0,0 +1,464 @@
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 { realpathSync } from 'node:fs';
23
+ import { readdir, readFile } from 'node:fs/promises';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ if (isMainModule()) {
27
+ const args = parseArgs(process.argv.slice(2));
28
+ try {
29
+ const result = await validateContent(args);
30
+ console.log(JSON.stringify(result, null, 2));
31
+ if (!result.success)
32
+ process.exitCode = 1;
33
+ }
34
+ catch (error) {
35
+ console.error(JSON.stringify({
36
+ success: false,
37
+ errors: [{ message: String(error?.message ?? error) }],
38
+ }, null, 2));
39
+ process.exitCode = 1;
40
+ }
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // Main
44
+ // ---------------------------------------------------------------------------
45
+ async function validateContent(options) {
46
+ if (!options.registry)
47
+ throw new Error('missing --registry (path to compiled registry directory)');
48
+ if (!options.compiled)
49
+ throw new Error('missing --compiled (path to compiled content output directory)');
50
+ const registryDir = path.resolve(options.registry);
51
+ const contentRoot = path.resolve(options.compiled);
52
+ // Load compiled registry
53
+ const registryPath = path.join(registryDir, 'registry.json');
54
+ const registryRaw = await readFile(registryPath, 'utf-8');
55
+ const registry = JSON.parse(registryRaw);
56
+ // Build schema map: component name → JSON Schema object
57
+ const schemaMap = new Map();
58
+ const knownComponents = new Set();
59
+ const componentMap = new Map();
60
+ for (const component of registry.components) {
61
+ knownComponents.add(component.name);
62
+ componentMap.set(component.name, component);
63
+ if (component.props) {
64
+ const schemaPath = path.join(registryDir, component.props);
65
+ const schemaRaw = await readFile(schemaPath, 'utf-8');
66
+ schemaMap.set(component.name, JSON.parse(schemaRaw));
67
+ }
68
+ }
69
+ // Find all compiled route files
70
+ const routesDir = path.join(contentRoot, 'routes');
71
+ const pageFiles = await findJsonFiles(routesDir);
72
+ const errors = [];
73
+ const warnings = [];
74
+ let pagesValidated = 0;
75
+ let blocksValidated = 0;
76
+ for (const pageFile of pageFiles) {
77
+ const relativePath = path.relative(contentRoot, pageFile);
78
+ let pageData;
79
+ try {
80
+ const raw = await readFile(pageFile, 'utf-8');
81
+ pageData = JSON.parse(raw);
82
+ }
83
+ catch (err) {
84
+ errors.push({ page: relativePath, message: `failed to parse JSON: ${err.message}` });
85
+ continue;
86
+ }
87
+ pagesValidated++;
88
+ const isPublished = pageData.status === 'published';
89
+ const collector = isPublished ? errors : warnings;
90
+ const blocks = collectBlocks(pageData);
91
+ for (const block of blocks) {
92
+ blocksValidated++;
93
+ if (!block || typeof block !== 'object') {
94
+ collector.push({
95
+ page: relativePath,
96
+ blockId: '<unknown>',
97
+ message: 'page region entry must be a component block',
98
+ });
99
+ continue;
100
+ }
101
+ if (block.$type !== 'component') {
102
+ collector.push({
103
+ page: relativePath,
104
+ blockId: block.id ?? '<unknown>',
105
+ path: '$type',
106
+ message: 'block "$type" must be "component"',
107
+ });
108
+ }
109
+ if (!block.component) {
110
+ collector.push({
111
+ page: relativePath,
112
+ blockId: block.id ?? '<unknown>',
113
+ message: 'block is missing "component" field',
114
+ });
115
+ continue;
116
+ }
117
+ // Check component exists in registry
118
+ if (!knownComponents.has(block.component)) {
119
+ collector.push({
120
+ page: relativePath,
121
+ blockId: block.id ?? '<unknown>',
122
+ component: block.component,
123
+ message: `unknown component: "${block.component}"`,
124
+ });
125
+ continue;
126
+ }
127
+ // Validate props against JSON Schema
128
+ const schema = schemaMap.get(block.component);
129
+ if (!schema)
130
+ continue; // no schema file — component registered but no schema to check
131
+ const props = block.props ?? {};
132
+ const issues = validateJsonSchema(props, schema, 'props');
133
+ for (const issue of issues) {
134
+ collector.push({
135
+ page: relativePath,
136
+ blockId: block.id ?? '<unknown>',
137
+ component: block.component,
138
+ path: issue.path,
139
+ message: issue.message,
140
+ });
141
+ }
142
+ const nestedIssues = validateNestedSlots(block, componentMap, schemaMap);
143
+ for (const issue of nestedIssues) {
144
+ collector.push({
145
+ page: relativePath,
146
+ blockId: block.id ?? '<unknown>',
147
+ component: block.component,
148
+ ...issue,
149
+ });
150
+ }
151
+ }
152
+ }
153
+ // Also validate fragments
154
+ const fragmentsDir = path.join(contentRoot, 'fragments');
155
+ const fragmentFiles = await findJsonFiles(fragmentsDir);
156
+ for (const fragmentFile of fragmentFiles) {
157
+ const relativePath = path.relative(contentRoot, fragmentFile);
158
+ let fragmentData;
159
+ try {
160
+ const raw = await readFile(fragmentFile, 'utf-8');
161
+ fragmentData = JSON.parse(raw);
162
+ }
163
+ catch (err) {
164
+ errors.push({ fragment: relativePath, message: `failed to parse JSON: ${err.message}` });
165
+ continue;
166
+ }
167
+ if (!fragmentData.component)
168
+ continue;
169
+ blocksValidated++;
170
+ if (!knownComponents.has(fragmentData.component)) {
171
+ errors.push({
172
+ fragment: relativePath,
173
+ component: fragmentData.component,
174
+ message: `unknown component: "${fragmentData.component}"`,
175
+ });
176
+ continue;
177
+ }
178
+ const schema = schemaMap.get(fragmentData.component);
179
+ if (!schema)
180
+ continue;
181
+ const props = fragmentData.props ?? {};
182
+ const issues = validateJsonSchema(props, schema, 'props');
183
+ for (const issue of issues) {
184
+ errors.push({
185
+ fragment: relativePath,
186
+ component: fragmentData.component,
187
+ path: issue.path,
188
+ message: issue.message,
189
+ });
190
+ }
191
+ }
192
+ const success = errors.length === 0;
193
+ return {
194
+ success,
195
+ pagesValidated,
196
+ blocksValidated,
197
+ errors,
198
+ ...(warnings.length > 0 ? { warnings } : {}),
199
+ };
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Lightweight JSON Schema draft-2020-12 validator
203
+ //
204
+ // Covers the subset that Zod generates via z.toJSONSchema:
205
+ // type, required, properties, additionalProperties,
206
+ // minLength, minimum, maximum, enum, const,
207
+ // items, minItems, maxItems, anyOf, allOf, $ref (local)
208
+ // ---------------------------------------------------------------------------
209
+ function validateJsonSchema(value, schema, pathPrefix = '') {
210
+ const issues = [];
211
+ _validate(value, schema, pathPrefix, issues, schema);
212
+ return issues;
213
+ }
214
+ function _validate(value, schema, currentPath, issues, rootSchema) {
215
+ if (!schema || typeof schema !== 'object')
216
+ return;
217
+ // $ref — resolve local references
218
+ if (schema.$ref) {
219
+ const resolved = resolveRef(schema.$ref, rootSchema);
220
+ if (resolved) {
221
+ _validate(value, resolved, currentPath, issues, rootSchema);
222
+ }
223
+ return;
224
+ }
225
+ // anyOf — value must match at least one
226
+ if (Array.isArray(schema.anyOf)) {
227
+ const anyMatches = schema.anyOf.some((sub) => {
228
+ const subIssues = [];
229
+ _validate(value, sub, currentPath, subIssues, rootSchema);
230
+ return subIssues.length === 0;
231
+ });
232
+ if (!anyMatches) {
233
+ issues.push({ path: currentPath, message: `${currentPath}: does not match any allowed schema` });
234
+ }
235
+ return;
236
+ }
237
+ // allOf — value must match all
238
+ if (Array.isArray(schema.allOf)) {
239
+ for (const sub of schema.allOf) {
240
+ _validate(value, sub, currentPath, issues, rootSchema);
241
+ }
242
+ return;
243
+ }
244
+ // const
245
+ if ('const' in schema) {
246
+ if (value !== schema.const) {
247
+ issues.push({ path: currentPath, message: `${currentPath}: expected constant "${schema.const}", got ${JSON.stringify(value)}` });
248
+ }
249
+ return;
250
+ }
251
+ // enum
252
+ if (Array.isArray(schema.enum)) {
253
+ if (!schema.enum.includes(value)) {
254
+ issues.push({ path: currentPath, message: `${currentPath}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}` });
255
+ }
256
+ return;
257
+ }
258
+ // type checking
259
+ const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null;
260
+ if (types) {
261
+ const actualType = getJsonType(value);
262
+ const typeMatch = types.some((t) => t === actualType || (t === 'integer' && actualType === 'number' && Number.isInteger(value)));
263
+ if (!typeMatch) {
264
+ // Allow null for nullable schemas
265
+ if (value !== null || !types.includes('null')) {
266
+ issues.push({ path: currentPath, message: `${currentPath}: expected type ${types.join('|')}, got ${actualType}` });
267
+ return; // Stop further checks if type is wrong
268
+ }
269
+ }
270
+ }
271
+ if (value === null || value === undefined)
272
+ return;
273
+ // String checks
274
+ if (typeof value === 'string') {
275
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
276
+ issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} is less than minLength ${schema.minLength}` });
277
+ }
278
+ if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
279
+ issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} exceeds maxLength ${schema.maxLength}` });
280
+ }
281
+ }
282
+ // Number checks
283
+ if (typeof value === 'number') {
284
+ if (typeof schema.minimum === 'number' && value < schema.minimum) {
285
+ issues.push({ path: currentPath, message: `${currentPath}: ${value} is less than minimum ${schema.minimum}` });
286
+ }
287
+ if (typeof schema.maximum === 'number' && value > schema.maximum) {
288
+ issues.push({ path: currentPath, message: `${currentPath}: ${value} exceeds maximum ${schema.maximum}` });
289
+ }
290
+ }
291
+ // Array checks
292
+ if (Array.isArray(value)) {
293
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
294
+ issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, minimum is ${schema.minItems}` });
295
+ }
296
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
297
+ issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, maximum is ${schema.maxItems}` });
298
+ }
299
+ if (schema.items) {
300
+ for (let i = 0; i < value.length; i++) {
301
+ _validate(value[i], schema.items, `${currentPath}[${i}]`, issues, rootSchema);
302
+ }
303
+ }
304
+ }
305
+ // Object checks
306
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
307
+ // Required fields
308
+ if (Array.isArray(schema.required)) {
309
+ for (const key of schema.required) {
310
+ if (!(key in value) || value[key] === undefined) {
311
+ issues.push({ path: `${currentPath}.${key}`, message: `${currentPath}.${key}: required field is missing` });
312
+ }
313
+ }
314
+ }
315
+ // Properties
316
+ if (schema.properties) {
317
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
318
+ if (key in value) {
319
+ _validate(value[key], propSchema, `${currentPath}.${key}`, issues, rootSchema);
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ function getJsonType(value) {
326
+ if (value === null)
327
+ return 'null';
328
+ if (Array.isArray(value))
329
+ return 'array';
330
+ return typeof value;
331
+ }
332
+ function resolveRef(ref, rootSchema) {
333
+ if (!ref.startsWith('#/'))
334
+ return null;
335
+ const parts = ref.slice(2).split('/');
336
+ let current = rootSchema;
337
+ for (const part of parts) {
338
+ current = current?.[part];
339
+ }
340
+ return current ?? null;
341
+ }
342
+ // ---------------------------------------------------------------------------
343
+ // Content helpers
344
+ // ---------------------------------------------------------------------------
345
+ // ---------------------------------------------------------------------------
346
+ // Block ref validation — recursively validates nested blocks inside props.
347
+ // ---------------------------------------------------------------------------
348
+ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props') {
349
+ const issues = [];
350
+ const parentComponent = componentMap.get(parentBlock.component);
351
+ if (!parentComponent)
352
+ return issues;
353
+ const blockSlots = parentComponent.slots ?? {};
354
+ for (const [slotName, slot] of Object.entries(blockSlots)) {
355
+ const blocks = parentBlock.props?.[slotName];
356
+ if (!Array.isArray(blocks)) {
357
+ if (slot.required) {
358
+ issues.push({ path: `${path}.${slotName}`, message: `${parentBlock.component}: required block ref "${slotName}" has no children` });
359
+ }
360
+ continue;
361
+ }
362
+ if (!slot.multiple && blocks.length > 1) {
363
+ issues.push({ path: `${path}.${slotName}`, message: `${parentBlock.component}: block ref "${slotName}" allows at most one child` });
364
+ }
365
+ for (const [index, nestedBlock] of blocks.entries()) {
366
+ const nestedPath = `${path}.${slotName}[${index}]`;
367
+ if (!nestedBlock || typeof nestedBlock !== 'object' || typeof nestedBlock.component !== 'string') {
368
+ issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
369
+ continue;
370
+ }
371
+ if (nestedBlock.$type !== 'component') {
372
+ issues.push({
373
+ path: `${nestedPath}.$type`,
374
+ message: `${parentBlock.component}: block ref child "$type" must be "component"`,
375
+ });
376
+ continue;
377
+ }
378
+ if (!componentMap.has(nestedBlock.component)) {
379
+ issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
380
+ continue;
381
+ }
382
+ if (!slot.allowed.includes(nestedBlock.component)) {
383
+ issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: component "${nestedBlock.component}" is not allowed in block ref "${slotName}"` });
384
+ }
385
+ const childSchema = schemaMap.get(nestedBlock.component);
386
+ const childProps = nestedBlock.props ?? {};
387
+ if (childSchema) {
388
+ for (const issue of validateJsonSchema(childProps, childSchema, `${nestedPath}.props`)) {
389
+ issues.push({ nestedComponent: nestedBlock.component, path: issue.path, message: `${parentBlock.component} → ${nestedBlock.component}: ${issue.message}` });
390
+ }
391
+ }
392
+ issues.push(...validateNestedSlots(nestedBlock, componentMap, schemaMap, nestedPath));
393
+ }
394
+ }
395
+ return issues;
396
+ }
397
+ function collectBlocks(pageData) {
398
+ const blocks = [];
399
+ if (pageData.regions && typeof pageData.regions === 'object') {
400
+ for (const region of Object.values(pageData.regions)) {
401
+ if (Array.isArray(region))
402
+ blocks.push(...region);
403
+ else if (region && typeof region === 'object' && Array.isArray(region.children))
404
+ blocks.push(...region.children);
405
+ }
406
+ }
407
+ if (Array.isArray(pageData.blocks))
408
+ blocks.push(...pageData.blocks);
409
+ return blocks;
410
+ }
411
+ async function findPageFiles(dir) {
412
+ return findFilesMatching(dir, (name) => name === '_index.json');
413
+ }
414
+ async function findJsonFiles(dir) {
415
+ return findFilesMatching(dir, (name) => name.endsWith('.json'));
416
+ }
417
+ async function findFilesMatching(dir, predicate) {
418
+ const results = [];
419
+ try {
420
+ await collectFiles(dir, predicate, results);
421
+ }
422
+ catch (err) {
423
+ if (err.code !== 'ENOENT')
424
+ throw err;
425
+ }
426
+ return results;
427
+ }
428
+ async function collectFiles(dir, predicate, results) {
429
+ const entries = await readdir(dir, { withFileTypes: true });
430
+ for (const entry of entries) {
431
+ const full = path.join(dir, entry.name);
432
+ if (entry.isDirectory())
433
+ await collectFiles(full, predicate, results);
434
+ else if (predicate(entry.name))
435
+ results.push(full);
436
+ }
437
+ }
438
+ function parseArgs(argv) {
439
+ const out = {};
440
+ for (let i = 0; i < argv.length; i++) {
441
+ const arg = argv[i];
442
+ if (!arg.startsWith('--'))
443
+ continue;
444
+ const body = arg.slice(2);
445
+ const equals = body.indexOf('=');
446
+ if (equals >= 0) {
447
+ out[body.slice(0, equals)] = body.slice(equals + 1);
448
+ continue;
449
+ }
450
+ out[body] = argv[++i];
451
+ }
452
+ return out;
453
+ }
454
+ function isMainModule() {
455
+ if (!process.argv[1])
456
+ return false;
457
+ try {
458
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ }
464
+ export { validateContent };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};