@brandlab-digital/conformance 0.1.0-alpha.9 → 1.0.0-rc.1
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/dist/cli.d.ts +19 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +128 -0
- package/dist/cli.js.map +1 -0
- package/dist/locations.d.ts +63 -0
- package/dist/locations.d.ts.map +1 -0
- package/dist/locations.js +105 -0
- package/dist/locations.js.map +1 -0
- package/dist/project-config.d.ts +31 -0
- package/dist/project-config.d.ts.map +1 -0
- package/dist/project-config.js +917 -0
- package/dist/project-config.js.map +1 -0
- package/dist/project.d.ts +67 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +1039 -0
- package/dist/project.js.map +1 -0
- package/dist/release-catalog.d.ts +4 -0
- package/dist/release-catalog.d.ts.map +1 -0
- package/dist/release-catalog.js +256 -0
- package/dist/release-catalog.js.map +1 -0
- package/dist/release-manifest.d.ts +119 -0
- package/dist/release-manifest.d.ts.map +1 -0
- package/dist/release-manifest.js +445 -0
- package/dist/release-manifest.js.map +1 -0
- package/dist/schema-hygiene.d.ts +25 -0
- package/dist/schema-hygiene.d.ts.map +1 -0
- package/dist/schema-hygiene.js +19 -0
- package/dist/schema-hygiene.js.map +1 -0
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +382 -6
- package/dist/vitest.js.map +1 -1
- package/package.json +35 -1
package/dist/vitest.js
CHANGED
|
@@ -12,6 +12,7 @@ const DEFAULT_EXTENSIONS = new Set([
|
|
|
12
12
|
const DEFAULT_IGNORED_DIRS = new Set([
|
|
13
13
|
"node_modules",
|
|
14
14
|
".next",
|
|
15
|
+
".vercel",
|
|
15
16
|
"dist",
|
|
16
17
|
"coverage",
|
|
17
18
|
]);
|
|
@@ -49,6 +50,19 @@ const DEFAULT_TOPOLOGY_ALLOWED_LIB_DIRS = [
|
|
|
49
50
|
];
|
|
50
51
|
const DEFAULT_TOPOLOGY_ALLOWED_TYPE_FILES = [];
|
|
51
52
|
const STANDARD_PACKAGE_MANAGER = "npm@11.14.1";
|
|
53
|
+
const STANDARD_NEXT_BASELINE = {
|
|
54
|
+
baseline: "2026-07",
|
|
55
|
+
nextVersion: "16.2.9",
|
|
56
|
+
reactVersion: "19.2.7",
|
|
57
|
+
reactDomVersion: "19.2.7",
|
|
58
|
+
node: "24.x",
|
|
59
|
+
minimumNode: "20.9.0",
|
|
60
|
+
router: "app",
|
|
61
|
+
bundler: "turbopack",
|
|
62
|
+
cacheComponents: false,
|
|
63
|
+
analyzeBundleScript: "next experimental-analyze",
|
|
64
|
+
imageQualities: [75, 90],
|
|
65
|
+
};
|
|
52
66
|
const STANDARD_TYPESCRIPT_TARGET = "ES2022";
|
|
53
67
|
const CONTENT_ARRAY_NAMES = [
|
|
54
68
|
"faqs",
|
|
@@ -430,11 +444,44 @@ const STANDARD_CONTACT_MODULE_FILES = new Set([
|
|
|
430
444
|
"useContactAttachments.ts",
|
|
431
445
|
"useFormDraftPersistence.ts",
|
|
432
446
|
]);
|
|
447
|
+
const CONTACT_EMAIL_CONTRACT_REQUIREMENTS = [
|
|
448
|
+
{
|
|
449
|
+
code: "brandlab-architecture-contact-email-submission-id",
|
|
450
|
+
pattern: /\bsubmissionId\b/,
|
|
451
|
+
detail: "src/lib/contact/ContactFormEmail.tsx must include the shared submissionId field.",
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
code: "brandlab-architecture-contact-email-page-path",
|
|
455
|
+
pattern: /\bpagePath\b/,
|
|
456
|
+
detail: "src/lib/contact/ContactFormEmail.tsx must include the submitted pagePath field.",
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
code: "brandlab-architecture-contact-email-page-url",
|
|
460
|
+
pattern: /\bpageUrl\b/,
|
|
461
|
+
detail: "src/lib/contact/ContactFormEmail.tsx must include the submitted pageUrl field.",
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
code: "brandlab-architecture-contact-email-traffic-source",
|
|
465
|
+
pattern: /\btrafficSource\b/,
|
|
466
|
+
detail: "src/lib/contact/ContactFormEmail.tsx must include the trafficSource field.",
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
code: "brandlab-architecture-contact-email-ad-click-rows",
|
|
470
|
+
pattern: /\b(adClickIds|getAdClickIdRows|formatAdClickIdsForEmailText)\b/,
|
|
471
|
+
detail: "src/lib/contact/ContactFormEmail.tsx must include ad-click ID rows.",
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
code: "brandlab-architecture-contact-email-attachments",
|
|
475
|
+
pattern: /\b(attachmentCount|attachments|formatContactFormEmailAttachmentSummary|getContactFormEmailContextRows)\b/,
|
|
476
|
+
detail: "src/lib/contact/ContactFormEmail.tsx must include an attachment summary.",
|
|
477
|
+
},
|
|
478
|
+
];
|
|
433
479
|
const ALLOWED_RICH_DATA_CONTENT_PATTERNS = [
|
|
434
480
|
/^src\/data\/site\/legal\/[^/]+\.(?:ts|tsx)$/,
|
|
435
481
|
];
|
|
436
482
|
const LEGACY_ENV_ARTIFACT_PATTERNS = [
|
|
437
483
|
/\bNEXT_PUBLIC_RECAPTCHA_SITE_KEY\b/,
|
|
484
|
+
/\bNEXT_PUBLIC_GOOGLE_MAPS(?:_PLATFORM)?_API_KEY\b/,
|
|
438
485
|
/\bRECAPTCHA_SECRET_KEY\b/,
|
|
439
486
|
/\bOUTLOOK_EMAIL\b/,
|
|
440
487
|
/\bOUTLOOK_PASSWORD\b/,
|
|
@@ -459,6 +506,10 @@ export const BRANDLAB_FORBIDDEN_SOURCE_PATTERNS = [
|
|
|
459
506
|
name: "sitemap filesystem freshness",
|
|
460
507
|
pattern: /\bfs\.statSync\b|\.mtime\b|\bmtime\b/,
|
|
461
508
|
},
|
|
509
|
+
{
|
|
510
|
+
name: "public Google Maps API key",
|
|
511
|
+
pattern: /\bNEXT_PUBLIC_GOOGLE_MAPS(?:_PLATFORM)?_API_KEY\b/,
|
|
512
|
+
},
|
|
462
513
|
];
|
|
463
514
|
function fileExtension(path) {
|
|
464
515
|
const match = path.match(/\.[^.]+$/);
|
|
@@ -502,6 +553,36 @@ function appFamilyForPage(path) {
|
|
|
502
553
|
return "home";
|
|
503
554
|
return withoutPrefix.split("/")[0] ?? "unknown";
|
|
504
555
|
}
|
|
556
|
+
function appRouteFileIsDynamic(path) {
|
|
557
|
+
return /\/\[[^\]]+\]\//.test(path) || /\[[^\]]+\]/.test(path);
|
|
558
|
+
}
|
|
559
|
+
function routeTemplateImportMatches(source) {
|
|
560
|
+
return [
|
|
561
|
+
...source.matchAll(/^\s*import\s+([^'"\n]+?)\s+from\s+['"](@\/components\/templates\/[^'"]+)['"]/gm),
|
|
562
|
+
].flatMap((match) => {
|
|
563
|
+
const importClause = match[1].trim();
|
|
564
|
+
const modulePath = match[2];
|
|
565
|
+
const names = [];
|
|
566
|
+
const defaultName = importClause
|
|
567
|
+
.replace(/\{[\s\S]*?\}/g, "")
|
|
568
|
+
.split(",")[0]
|
|
569
|
+
?.trim();
|
|
570
|
+
if (defaultName)
|
|
571
|
+
names.push(defaultName);
|
|
572
|
+
const namedClause = importClause.match(/\{([\s\S]*?)\}/)?.[1];
|
|
573
|
+
if (namedClause) {
|
|
574
|
+
for (const part of namedClause.split(",")) {
|
|
575
|
+
const name = part.trim().split(/\s+as\s+/).pop()?.trim();
|
|
576
|
+
if (name)
|
|
577
|
+
names.push(name);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return names.map((name) => ({
|
|
581
|
+
localName: name,
|
|
582
|
+
modulePath,
|
|
583
|
+
}));
|
|
584
|
+
});
|
|
585
|
+
}
|
|
505
586
|
function extractQuotedStrings(line) {
|
|
506
587
|
const strings = [];
|
|
507
588
|
let quote = null;
|
|
@@ -721,6 +802,65 @@ function getPackageVersion(packageJson, packageName) {
|
|
|
721
802
|
packageJson.devDependencies?.[packageName] ??
|
|
722
803
|
null);
|
|
723
804
|
}
|
|
805
|
+
function isExactVersionSpec(version) {
|
|
806
|
+
return typeof version === "string" && !/^[~^*]|latest|workspace:|file:|link:/.test(version);
|
|
807
|
+
}
|
|
808
|
+
function extractDelimitedProperty(source, propertyName, openChar, closeChar) {
|
|
809
|
+
const propertyMatch = source.match(new RegExp(`${propertyName}\\s*:`));
|
|
810
|
+
if (!propertyMatch || propertyMatch.index === undefined)
|
|
811
|
+
return null;
|
|
812
|
+
const start = source.indexOf(openChar, propertyMatch.index + propertyMatch[0].length);
|
|
813
|
+
if (start === -1)
|
|
814
|
+
return null;
|
|
815
|
+
let depth = 0;
|
|
816
|
+
let quote = null;
|
|
817
|
+
let escaped = false;
|
|
818
|
+
for (let index = start; index < source.length; index += 1) {
|
|
819
|
+
const char = source[index];
|
|
820
|
+
if (quote) {
|
|
821
|
+
if (escaped)
|
|
822
|
+
escaped = false;
|
|
823
|
+
else if (char === "\\")
|
|
824
|
+
escaped = true;
|
|
825
|
+
else if (char === quote)
|
|
826
|
+
quote = null;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (char === '"' || char === "'" || char === "`")
|
|
830
|
+
quote = char;
|
|
831
|
+
else if (char === openChar)
|
|
832
|
+
depth += 1;
|
|
833
|
+
else if (char === closeChar) {
|
|
834
|
+
depth -= 1;
|
|
835
|
+
if (depth === 0)
|
|
836
|
+
return source.slice(start + 1, index);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
function extractStringProperty(source, propertyName) {
|
|
842
|
+
return source.match(new RegExp(`${propertyName}\\s*:\\s*['"\`]([^'"\`]+)['"\`]`, "m"))?.[1] ?? null;
|
|
843
|
+
}
|
|
844
|
+
function parseBrandlabNextContract(source) {
|
|
845
|
+
const nextSource = extractDelimitedProperty(source, "next", "{", "}");
|
|
846
|
+
if (!nextSource)
|
|
847
|
+
return null;
|
|
848
|
+
return {
|
|
849
|
+
baseline: extractStringProperty(nextSource, "baseline"),
|
|
850
|
+
nextVersion: extractStringProperty(nextSource, "nextVersion"),
|
|
851
|
+
reactVersion: extractStringProperty(nextSource, "reactVersion"),
|
|
852
|
+
reactDomVersion: extractStringProperty(nextSource, "reactDomVersion"),
|
|
853
|
+
node: extractStringProperty(nextSource, "node"),
|
|
854
|
+
minimumNode: extractStringProperty(nextSource, "minimumNode"),
|
|
855
|
+
router: extractStringProperty(nextSource, "router"),
|
|
856
|
+
bundler: extractStringProperty(nextSource, "bundler"),
|
|
857
|
+
cacheComponents: /cacheComponents\s*:\s*true/m.test(nextSource)
|
|
858
|
+
? true
|
|
859
|
+
: /cacheComponents\s*:\s*false/m.test(nextSource)
|
|
860
|
+
? false
|
|
861
|
+
: null,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
724
864
|
export function evaluateSharedPackageVersionSpecs(options) {
|
|
725
865
|
const issues = [];
|
|
726
866
|
for (const packageName of options.packageNames) {
|
|
@@ -1180,6 +1320,7 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
1180
1320
|
severity: "error",
|
|
1181
1321
|
code: "brandlab-architecture-component-page-module",
|
|
1182
1322
|
detail: `${relativePath} is a route-owned page module inside components. Standard projects keep route files in src/app, page data in src/data, and reusable templates directly under src/components/templates.`,
|
|
1323
|
+
fix: "Move page-specific content and SEO records into src/data/site/pages, compose one-off static pages directly in the matching src/app route, and keep src/components/templates for dynamic route families or templates shared by multiple routes.",
|
|
1183
1324
|
path: relativePath,
|
|
1184
1325
|
});
|
|
1185
1326
|
}
|
|
@@ -1188,6 +1329,7 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
1188
1329
|
severity: "error",
|
|
1189
1330
|
code: "brandlab-architecture-component-static-page",
|
|
1190
1331
|
detail: `${relativePath} keeps a static page module inside components. Standard projects keep route ownership in src/app and page models in src/data.`,
|
|
1332
|
+
fix: "Move static page model data into src/data/site/pages and compose the one-off static page directly in the matching src/app route. Keep src/components/templates for dynamic route families or templates shared by multiple routes.",
|
|
1191
1333
|
path: relativePath,
|
|
1192
1334
|
});
|
|
1193
1335
|
}
|
|
@@ -1319,7 +1461,7 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
1319
1461
|
const directComponentImports = [
|
|
1320
1462
|
...source.matchAll(/from\s+['"](@\/components\/(?:sections|contact|ui)\/[^'"]+)['"]/g),
|
|
1321
1463
|
].map((match) => match[1]);
|
|
1322
|
-
if (directComponentImports.length > 0) {
|
|
1464
|
+
if (appRouteFileIsDynamic(relativePath) && directComponentImports.length > 0) {
|
|
1323
1465
|
issues.push({
|
|
1324
1466
|
severity: "error",
|
|
1325
1467
|
code: "brandlab-architecture-route-direct-component-composition",
|
|
@@ -1476,6 +1618,15 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
1476
1618
|
path: relativePath,
|
|
1477
1619
|
});
|
|
1478
1620
|
}
|
|
1621
|
+
if (/\baggregateRating\b|\bAggregateRating\b|['"]Review['"]|\breviewRating\b|\breview\s*:/.test(source)) {
|
|
1622
|
+
issues.push({
|
|
1623
|
+
severity: "error",
|
|
1624
|
+
code: "brandlab-architecture-self-serving-review-schema",
|
|
1625
|
+
detail: `${relativePath} emits Review or AggregateRating schema from a local service-business site. Google treats business-controlled LocalBusiness/Organization review markup as self-serving and ineligible for review snippets.`,
|
|
1626
|
+
fix: "Keep customer testimonials/reviews as visible page content only. Do not emit Review or AggregateRating JSON-LD for the client's own local business or services.",
|
|
1627
|
+
path: relativePath,
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1479
1630
|
if (!/export\s+function\s+createSchema(?:Graph)?\b/.test(source))
|
|
1480
1631
|
continue;
|
|
1481
1632
|
if (source.includes("@brandlab-digital/site-core/schema"))
|
|
@@ -1803,6 +1954,16 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
1803
1954
|
path: "src/lib/contact/ContactFormEmail.tsx",
|
|
1804
1955
|
});
|
|
1805
1956
|
}
|
|
1957
|
+
for (const requirement of CONTACT_EMAIL_CONTRACT_REQUIREMENTS) {
|
|
1958
|
+
if (contactEmailSource && !requirement.pattern.test(contactEmailSource)) {
|
|
1959
|
+
issues.push({
|
|
1960
|
+
severity: "error",
|
|
1961
|
+
code: requirement.code,
|
|
1962
|
+
detail: requirement.detail,
|
|
1963
|
+
path: "src/lib/contact/ContactFormEmail.tsx",
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1806
1967
|
if (/message\s*:\s*error\s+instanceof\s+Error\s*&&\s*error\.message/.test(actionsSource)) {
|
|
1807
1968
|
issues.push({
|
|
1808
1969
|
severity: "error",
|
|
@@ -1923,6 +2084,49 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
1923
2084
|
path: "package.json",
|
|
1924
2085
|
});
|
|
1925
2086
|
}
|
|
2087
|
+
for (const [packageName, expectedVersion] of [
|
|
2088
|
+
["next", STANDARD_NEXT_BASELINE.nextVersion],
|
|
2089
|
+
["react", STANDARD_NEXT_BASELINE.reactVersion],
|
|
2090
|
+
["react-dom", STANDARD_NEXT_BASELINE.reactDomVersion],
|
|
2091
|
+
]) {
|
|
2092
|
+
const installedVersion = getPackageVersion(packageJson, packageName);
|
|
2093
|
+
if (installedVersion === expectedVersion && isExactVersionSpec(installedVersion))
|
|
2094
|
+
continue;
|
|
2095
|
+
issues.push({
|
|
2096
|
+
severity: "error",
|
|
2097
|
+
code: "brandlab-architecture-next-package-version",
|
|
2098
|
+
detail: `package.json installs ${packageName} as "${installedVersion ?? "missing"}"; expected exact "${expectedVersion}".`,
|
|
2099
|
+
path: "package.json",
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
if (packageJson.engines?.node !== STANDARD_NEXT_BASELINE.node) {
|
|
2103
|
+
issues.push({
|
|
2104
|
+
severity: "error",
|
|
2105
|
+
code: "brandlab-architecture-next-node-engine",
|
|
2106
|
+
detail: `package.json engines.node is "${packageJson.engines?.node ?? "missing"}"; expected "${STANDARD_NEXT_BASELINE.node}".`,
|
|
2107
|
+
path: "package.json",
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
const webpackScripts = Object.entries(packageJson.scripts ?? {})
|
|
2111
|
+
.filter(([, script]) => typeof script === "string" && /--webpack/.test(script))
|
|
2112
|
+
.map(([scriptName]) => scriptName);
|
|
2113
|
+
if (webpackScripts.length > 0) {
|
|
2114
|
+
issues.push({
|
|
2115
|
+
severity: "error",
|
|
2116
|
+
code: "brandlab-architecture-next-webpack-script",
|
|
2117
|
+
detail: `package.json contains webpack fallback scripts: ${webpackScripts.join(", ")}.`,
|
|
2118
|
+
path: "package.json",
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
if (packageJson.scripts?.["analyze:bundle"] &&
|
|
2122
|
+
packageJson.scripts["analyze:bundle"] !== STANDARD_NEXT_BASELINE.analyzeBundleScript) {
|
|
2123
|
+
issues.push({
|
|
2124
|
+
severity: "error",
|
|
2125
|
+
code: "brandlab-architecture-next-bundle-analysis-script",
|
|
2126
|
+
detail: `package.json analyze:bundle is "${packageJson.scripts["analyze:bundle"]}"; expected "${STANDARD_NEXT_BASELINE.analyzeBundleScript}".`,
|
|
2127
|
+
path: "package.json",
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
1926
2130
|
}
|
|
1927
2131
|
const tsconfigPath = join(options.root, "tsconfig.json");
|
|
1928
2132
|
if (existsSync(tsconfigPath)) {
|
|
@@ -2133,6 +2337,48 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2133
2337
|
const brandlabConfigSource = existsSync(brandlabConfigPath)
|
|
2134
2338
|
? readFileSync(brandlabConfigPath, "utf8")
|
|
2135
2339
|
: "";
|
|
2340
|
+
const brandlabNextContract = parseBrandlabNextContract(brandlabConfigSource);
|
|
2341
|
+
if (!brandlabNextContract) {
|
|
2342
|
+
issues.push({
|
|
2343
|
+
severity: "error",
|
|
2344
|
+
code: "brandlab-architecture-next-contract",
|
|
2345
|
+
detail: "brandlab.config.ts does not declare the Brandlab Next Baseline 2026-07 next contract.",
|
|
2346
|
+
path: "brandlab.config.ts",
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
else {
|
|
2350
|
+
for (const [fieldName, expectedValue] of [
|
|
2351
|
+
["baseline", STANDARD_NEXT_BASELINE.baseline],
|
|
2352
|
+
["nextVersion", STANDARD_NEXT_BASELINE.nextVersion],
|
|
2353
|
+
["reactVersion", STANDARD_NEXT_BASELINE.reactVersion],
|
|
2354
|
+
["reactDomVersion", STANDARD_NEXT_BASELINE.reactDomVersion],
|
|
2355
|
+
["node", STANDARD_NEXT_BASELINE.node],
|
|
2356
|
+
["minimumNode", STANDARD_NEXT_BASELINE.minimumNode],
|
|
2357
|
+
["router", STANDARD_NEXT_BASELINE.router],
|
|
2358
|
+
["bundler", STANDARD_NEXT_BASELINE.bundler],
|
|
2359
|
+
]) {
|
|
2360
|
+
if (brandlabNextContract[fieldName] === expectedValue)
|
|
2361
|
+
continue;
|
|
2362
|
+
issues.push({
|
|
2363
|
+
severity: "error",
|
|
2364
|
+
code: "brandlab-architecture-next-contract",
|
|
2365
|
+
detail: `brandlab.config.ts next.${fieldName} is "${brandlabNextContract[fieldName] ?? "missing"}"; expected "${expectedValue}".`,
|
|
2366
|
+
path: "brandlab.config.ts",
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
if (brandlabNextContract.cacheComponents !== STANDARD_NEXT_BASELINE.cacheComponents) {
|
|
2370
|
+
issues.push({
|
|
2371
|
+
severity: "error",
|
|
2372
|
+
code: "brandlab-architecture-next-cache-components",
|
|
2373
|
+
detail: "brandlab.config.ts enables cacheComponents outside the approved Brandlab pilot track.",
|
|
2374
|
+
path: "brandlab.config.ts",
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
const contactAttachmentsPath = join(options.root, "src/lib/contact/attachments.ts");
|
|
2379
|
+
const contactAttachmentsSource = existsSync(contactAttachmentsPath)
|
|
2380
|
+
? readFileSync(contactAttachmentsPath, "utf8")
|
|
2381
|
+
: "";
|
|
2136
2382
|
if (brandlabConfigSource.includes("contactAttachments") &&
|
|
2137
2383
|
!/serverActions\s*:\s*\{[\s\S]*bodySizeLimit\s*:\s*['"]20mb['"]/.test(nextConfigSource)) {
|
|
2138
2384
|
issues.push({
|
|
@@ -2142,6 +2388,67 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2142
2388
|
path: "next.config.ts",
|
|
2143
2389
|
});
|
|
2144
2390
|
}
|
|
2391
|
+
if (brandlabConfigSource.includes("contactAttachments") &&
|
|
2392
|
+
!/filenamePrefix\s*:\s*['"`][a-z0-9][a-z0-9-]{2,48}['"`]/.test(contactConstantsSource)) {
|
|
2393
|
+
issues.push({
|
|
2394
|
+
severity: "error",
|
|
2395
|
+
code: "brandlab-architecture-contact-attachment-prefix",
|
|
2396
|
+
detail: "Contact attachment policy does not define an explicit project-specific filenamePrefix.",
|
|
2397
|
+
path: "src/lib/contact/constants.ts",
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
if (existsSync(join(options.root, "pages")) || existsSync(join(options.root, "src/pages"))) {
|
|
2401
|
+
issues.push({
|
|
2402
|
+
severity: "error",
|
|
2403
|
+
code: "brandlab-architecture-pages-router-directory",
|
|
2404
|
+
detail: "Project contains pages/ or src/pages/. Brandlab Next Baseline is App Router only.",
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
for (const filePath of walkAllFiles(options.root)) {
|
|
2408
|
+
if (!/\.(?:ts|tsx|js|jsx)$/.test(filePath))
|
|
2409
|
+
continue;
|
|
2410
|
+
const relativePath = relative(options.root, filePath).split(/[/\\]/).join("/");
|
|
2411
|
+
if (relativePath.startsWith("node_modules/") ||
|
|
2412
|
+
relativePath.startsWith(".next/") ||
|
|
2413
|
+
relativePath.startsWith("dist/")) {
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
const source = readFileSync(filePath, "utf8");
|
|
2417
|
+
const pagesRouterApiPatterns = [
|
|
2418
|
+
["next/head", /\bfrom\s+['"]next\/head['"]/],
|
|
2419
|
+
["getStaticProps", /\bgetStaticProps\b/],
|
|
2420
|
+
["getServerSideProps", /\bgetServerSideProps\b/],
|
|
2421
|
+
];
|
|
2422
|
+
const pagesRouterApis = pagesRouterApiPatterns.filter(([, pattern]) => pattern.test(source));
|
|
2423
|
+
if (pagesRouterApis.length > 0) {
|
|
2424
|
+
issues.push({
|
|
2425
|
+
severity: "error",
|
|
2426
|
+
code: "brandlab-architecture-pages-router-api",
|
|
2427
|
+
detail: `${relativePath} references Pages Router APIs: ${pagesRouterApis.map(([label]) => label).join(", ")}.`,
|
|
2428
|
+
path: relativePath,
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
const invalidQualities = [...new Set([...source.matchAll(/\bquality\s*=\s*\{?\s*(\d+)\s*\}?/g)]
|
|
2432
|
+
.map((match) => Number(match[1]))
|
|
2433
|
+
.filter((quality) => !STANDARD_NEXT_BASELINE.imageQualities.includes(quality)))].sort((a, b) => a - b);
|
|
2434
|
+
if (invalidQualities.length > 0) {
|
|
2435
|
+
issues.push({
|
|
2436
|
+
severity: "error",
|
|
2437
|
+
code: "brandlab-architecture-next-image-quality",
|
|
2438
|
+
detail: `${relativePath} uses unsupported next/image quality values: ${invalidQualities.join(", ")}.`,
|
|
2439
|
+
path: relativePath,
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
if (/createContactAttachmentFilename\s*\(/.test(contactAttachmentsSource) &&
|
|
2444
|
+
!/(prefix\s*:\s*contactAttachmentPolicy\.filenamePrefix|policy\s*:\s*contactAttachmentPolicy)/.test(contactAttachmentsSource)) {
|
|
2445
|
+
issues.push({
|
|
2446
|
+
severity: "error",
|
|
2447
|
+
code: "brandlab-architecture-contact-attachment-filename",
|
|
2448
|
+
detail: "Contact attachment filename generation does not use the project-specific attachment filename prefix.",
|
|
2449
|
+
path: "src/lib/contact/attachments.ts",
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2145
2452
|
if (/const\s+staticLegacyMappings\s*[:=]/.test(nextConfigSource)) {
|
|
2146
2453
|
issues.push({
|
|
2147
2454
|
severity: "error",
|
|
@@ -2150,6 +2457,50 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2150
2457
|
path: "next.config.ts",
|
|
2151
2458
|
});
|
|
2152
2459
|
}
|
|
2460
|
+
if (/cacheComponents\s*:\s*true/.test(nextConfigSource)) {
|
|
2461
|
+
issues.push({
|
|
2462
|
+
severity: "error",
|
|
2463
|
+
code: "brandlab-architecture-next-cache-components",
|
|
2464
|
+
detail: "next.config.ts enables cacheComponents outside the approved Brandlab pilot track.",
|
|
2465
|
+
path: "next.config.ts",
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
if (!/images\s*:\s*\{/.test(nextConfigSource)) {
|
|
2469
|
+
issues.push({
|
|
2470
|
+
severity: "error",
|
|
2471
|
+
code: "brandlab-architecture-next-image-config",
|
|
2472
|
+
detail: "next.config.ts must define an explicit images config.",
|
|
2473
|
+
path: "next.config.ts",
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
else {
|
|
2477
|
+
for (const [code, detail, pattern] of [
|
|
2478
|
+
[
|
|
2479
|
+
"brandlab-architecture-next-image-qualities",
|
|
2480
|
+
"next.config.ts images.qualities must be [75, 90].",
|
|
2481
|
+
/qualities\s*:\s*\[\s*75\s*,\s*90\s*\]/,
|
|
2482
|
+
],
|
|
2483
|
+
[
|
|
2484
|
+
"brandlab-architecture-next-image-cache-ttl",
|
|
2485
|
+
"next.config.ts images.minimumCacheTTL is missing.",
|
|
2486
|
+
/minimumCacheTTL\s*:/,
|
|
2487
|
+
],
|
|
2488
|
+
[
|
|
2489
|
+
"brandlab-architecture-next-image-remote-patterns",
|
|
2490
|
+
"next.config.ts images.remotePatterns is missing.",
|
|
2491
|
+
/remotePatterns\s*:\s*\[/,
|
|
2492
|
+
],
|
|
2493
|
+
]) {
|
|
2494
|
+
if (pattern.test(nextConfigSource))
|
|
2495
|
+
continue;
|
|
2496
|
+
issues.push({
|
|
2497
|
+
severity: "error",
|
|
2498
|
+
code,
|
|
2499
|
+
detail,
|
|
2500
|
+
path: "next.config.ts",
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2153
2504
|
const envExamplePath = join(options.root, ".env.example");
|
|
2154
2505
|
if (existsSync(envExamplePath) &&
|
|
2155
2506
|
LEGACY_ENV_ARTIFACT_PATTERNS.some((pattern) => pattern.test(readFileSync(envExamplePath, "utf8")))) {
|
|
@@ -2194,6 +2545,7 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2194
2545
|
}
|
|
2195
2546
|
}
|
|
2196
2547
|
const familyCounts = new Map();
|
|
2548
|
+
const templateImportRouteCounts = new Map();
|
|
2197
2549
|
for (const filePath of pageFiles) {
|
|
2198
2550
|
const family = appFamilyForPage(filePath);
|
|
2199
2551
|
const current = familyCounts.get(family) ?? {
|
|
@@ -2208,7 +2560,16 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2208
2560
|
const source = readFileSync(join(options.root, filePath), "utf8");
|
|
2209
2561
|
const lineCount = source.split(/\r?\n/).length;
|
|
2210
2562
|
const routeOwnedContentChars = countRouteOwnedContentChars(source);
|
|
2211
|
-
|
|
2563
|
+
for (const templateImport of routeTemplateImportMatches(source)) {
|
|
2564
|
+
const key = `${templateImport.modulePath}::${templateImport.localName}`;
|
|
2565
|
+
const currentImport = templateImportRouteCounts.get(key) ?? {
|
|
2566
|
+
modulePath: templateImport.modulePath,
|
|
2567
|
+
routes: new Set(),
|
|
2568
|
+
};
|
|
2569
|
+
currentImport.routes.add(filePath);
|
|
2570
|
+
templateImportRouteCounts.set(key, currentImport);
|
|
2571
|
+
}
|
|
2572
|
+
if (appRouteFileIsDynamic(filePath) && lineCount > largeRouteFileLineLimit) {
|
|
2212
2573
|
issues.push({
|
|
2213
2574
|
severity: "error",
|
|
2214
2575
|
code: "large-route-file",
|
|
@@ -2216,15 +2577,15 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2216
2577
|
path: filePath,
|
|
2217
2578
|
});
|
|
2218
2579
|
}
|
|
2219
|
-
if (routeOwnedContentChars > routeFileContentCharLimit) {
|
|
2580
|
+
if (appRouteFileIsDynamic(filePath) && routeOwnedContentChars > routeFileContentCharLimit) {
|
|
2220
2581
|
issues.push({
|
|
2221
2582
|
severity: "error",
|
|
2222
2583
|
code: "route-owned-page-copy",
|
|
2223
|
-
detail: `
|
|
2584
|
+
detail: `Dynamic route file contains about ${routeOwnedContentChars} characters of customer-facing copy. Standard dynamic route families should orchestrate data and components, not own repeated page copy.`,
|
|
2224
2585
|
path: filePath,
|
|
2225
2586
|
});
|
|
2226
2587
|
}
|
|
2227
|
-
if (CONTENT_ARRAY_PATTERN.test(source)) {
|
|
2588
|
+
if (appRouteFileIsDynamic(filePath) && CONTENT_ARRAY_PATTERN.test(source)) {
|
|
2228
2589
|
issues.push({
|
|
2229
2590
|
severity: "error",
|
|
2230
2591
|
code: "embedded-route-content-array",
|
|
@@ -2240,7 +2601,9 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2240
2601
|
path: filePath,
|
|
2241
2602
|
});
|
|
2242
2603
|
}
|
|
2243
|
-
const copyMatches =
|
|
2604
|
+
const copyMatches = appRouteFileIsDynamic(filePath)
|
|
2605
|
+
? routeOwnedCopyPropertyMatches(source)
|
|
2606
|
+
: [];
|
|
2244
2607
|
if (copyMatches.length > 0) {
|
|
2245
2608
|
const propertyNames = [
|
|
2246
2609
|
...new Set(copyMatches.map((match) => match.propertyName)),
|
|
@@ -2277,6 +2640,19 @@ export function evaluateBrandlabArchitecture(options) {
|
|
|
2277
2640
|
});
|
|
2278
2641
|
}
|
|
2279
2642
|
}
|
|
2643
|
+
for (const { modulePath, routes } of templateImportRouteCounts.values()) {
|
|
2644
|
+
if (routes.size !== 1)
|
|
2645
|
+
continue;
|
|
2646
|
+
const [routePath] = [...routes];
|
|
2647
|
+
if (appRouteFileIsDynamic(routePath))
|
|
2648
|
+
continue;
|
|
2649
|
+
issues.push({
|
|
2650
|
+
severity: "error",
|
|
2651
|
+
code: "brandlab-architecture-single-use-page-template",
|
|
2652
|
+
detail: `${routePath} imports ${modulePath}, but that template is only used by one static route. Static one-off pages should compose page sections directly in src/app; keep src/components/templates for reusable dynamic route families or templates shared by multiple routes.`,
|
|
2653
|
+
path: routePath,
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2280
2656
|
const staticRouteFileLimit = architecture?.staticRouteFileLimit ?? DEFAULT_STATIC_ROUTE_FILE_LIMIT;
|
|
2281
2657
|
for (const [family, counts] of familyCounts.entries()) {
|
|
2282
2658
|
if (counts.staticCount > staticRouteFileLimit &&
|