@brandlab-digital/conformance 3.12.1 → 3.13.0
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/README.md +32 -1
- package/dist/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/edge-probe-cli.d.ts +57 -0
- package/dist/edge-probe-cli.d.ts.map +1 -0
- package/dist/edge-probe-cli.js +134 -0
- package/dist/edge-probe-cli.js.map +1 -0
- package/dist/engineering.d.ts +4 -1
- package/dist/engineering.d.ts.map +1 -1
- package/dist/engineering.js +19 -14
- package/dist/engineering.js.map +1 -1
- package/dist/estate-sample.d.ts +60 -0
- package/dist/estate-sample.d.ts.map +1 -0
- package/dist/estate-sample.js +144 -0
- package/dist/estate-sample.js.map +1 -0
- package/dist/estate.d.ts +17 -0
- package/dist/estate.d.ts.map +1 -1
- package/dist/estate.js +47 -10
- package/dist/estate.js.map +1 -1
- package/dist/origin-region.d.ts +14 -0
- package/dist/origin-region.d.ts.map +1 -0
- package/dist/origin-region.js +46 -0
- package/dist/origin-region.js.map +1 -0
- package/dist/prefetch-probe-cli.d.ts +36 -0
- package/dist/prefetch-probe-cli.d.ts.map +1 -0
- package/dist/prefetch-probe-cli.js +163 -0
- package/dist/prefetch-probe-cli.js.map +1 -0
- package/dist/site-system/checks.d.ts +36 -0
- package/dist/site-system/checks.d.ts.map +1 -1
- package/dist/site-system/checks.js +6 -0
- package/dist/site-system/checks.js.map +1 -1
- package/dist/site-system/config.d.ts +27 -0
- package/dist/site-system/config.d.ts.map +1 -1
- package/dist/site-system/config.js +50 -1
- package/dist/site-system/config.js.map +1 -1
- package/dist/site-system/index.d.ts +1 -1
- package/dist/site-system/index.d.ts.map +1 -1
- package/dist/site-system/index.js +1 -1
- package/dist/site-system/index.js.map +1 -1
- package/dist/site-system/journeys/plan.d.ts +9 -2
- package/dist/site-system/journeys/plan.d.ts.map +1 -1
- package/dist/site-system/journeys/plan.js +11 -1
- package/dist/site-system/journeys/plan.js.map +1 -1
- package/dist/site-system/journeys/prefetch-measure.d.ts +28 -0
- package/dist/site-system/journeys/prefetch-measure.d.ts.map +1 -0
- package/dist/site-system/journeys/prefetch-measure.js +94 -0
- package/dist/site-system/journeys/prefetch-measure.js.map +1 -0
- package/dist/site-system/journeys/prefetch-policy.d.ts +85 -0
- package/dist/site-system/journeys/prefetch-policy.d.ts.map +1 -0
- package/dist/site-system/journeys/prefetch-policy.js +152 -0
- package/dist/site-system/journeys/prefetch-policy.js.map +1 -0
- package/dist/site-system/journeys/prefetch.d.ts +3 -0
- package/dist/site-system/journeys/prefetch.d.ts.map +1 -0
- package/dist/site-system/journeys/prefetch.js +37 -0
- package/dist/site-system/journeys/prefetch.js.map +1 -0
- package/dist/site-system/journeys/variants.d.ts +34 -0
- package/dist/site-system/journeys/variants.d.ts.map +1 -0
- package/dist/site-system/journeys/variants.js +139 -0
- package/dist/site-system/journeys/variants.js.map +1 -0
- package/dist/site-system/rule-modules.d.ts.map +1 -1
- package/dist/site-system/rule-modules.js +5 -0
- package/dist/site-system/rule-modules.js.map +1 -1
- package/dist/site-system/rules/full/common.d.ts +3 -0
- package/dist/site-system/rules/full/common.d.ts.map +1 -1
- package/dist/site-system/rules/full/common.js.map +1 -1
- package/dist/site-system/rules/full/estate.d.ts +4 -0
- package/dist/site-system/rules/full/estate.d.ts.map +1 -1
- package/dist/site-system/rules/full/estate.js +11 -2
- package/dist/site-system/rules/full/estate.js.map +1 -1
- package/dist/site-system/rules/full/index.d.ts +2 -1
- package/dist/site-system/rules/full/index.d.ts.map +1 -1
- package/dist/site-system/rules/full/index.js +14 -7
- package/dist/site-system/rules/full/index.js.map +1 -1
- package/dist/site-system/rules/full/w3-refresh.d.ts +22 -0
- package/dist/site-system/rules/full/w3-refresh.d.ts.map +1 -0
- package/dist/site-system/rules/full/w3-refresh.js +70 -0
- package/dist/site-system/rules/full/w3-refresh.js.map +1 -0
- package/dist/site-system/rules/guards/index.d.ts +22 -0
- package/dist/site-system/rules/guards/index.d.ts.map +1 -0
- package/dist/site-system/rules/guards/index.js +109 -0
- package/dist/site-system/rules/guards/index.js.map +1 -0
- package/dist/site-system/rules/guards/w10-crawlers.d.ts +55 -0
- package/dist/site-system/rules/guards/w10-crawlers.d.ts.map +1 -0
- package/dist/site-system/rules/guards/w10-crawlers.js +182 -0
- package/dist/site-system/rules/guards/w10-crawlers.js.map +1 -0
- package/dist/site-system/rules/guards/w9-params.d.ts +19 -0
- package/dist/site-system/rules/guards/w9-params.d.ts.map +1 -0
- package/dist/site-system/rules/guards/w9-params.js +84 -0
- package/dist/site-system/rules/guards/w9-params.js.map +1 -0
- package/dist/site-system-e2e.d.ts +1 -0
- package/dist/site-system-e2e.d.ts.map +1 -1
- package/dist/site-system-e2e.js +8 -2
- package/dist/site-system-e2e.js.map +1 -1
- package/package.json +6 -4
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
2
|
+
import type { SiteSystemFetch } from "../../samples.js";
|
|
3
|
+
import type { SiteSystemRenderedInput } from "../../runners.js";
|
|
4
|
+
export { checkW9Params, dynamicRoutePageFiles, searchParamLines } from "./w9-params.js";
|
|
5
|
+
export { checkW10, firewallRules, parseRobotsTxt, robotsAllows, robotsGroupsFor, userAgentConditionMatches, type FirewallRule, type RobotsGroup, type UserAgentCondition, type W10Input } from "./w10-crawlers.js";
|
|
6
|
+
export interface CostGuardRuleOptions {
|
|
7
|
+
/** The moment W5 judges a decision's reviewBy on (in Sydney). Defaults to now. */
|
|
8
|
+
readonly now?: Date;
|
|
9
|
+
/** Fetches robots.txt with --base-url (tests). */
|
|
10
|
+
readonly fetch?: SiteSystemFetch;
|
|
11
|
+
}
|
|
12
|
+
export declare function checkW5(projectRoot: string, today: string): SiteSystemFinding[];
|
|
13
|
+
/** True when robots.txt shuts every search and answer crawler out of the whole site (`Disallow: /`). */
|
|
14
|
+
export declare function robotsClosedToEveryCrawler(text: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The deployment environment a Next build was made for: the `NEXT_PUBLIC_VERCEL_ENV` that defineBrandlabNextConfig
|
|
17
|
+
* inlines (from VERCEL_ENV), as the build records it in required-server-files.json. Undefined when it is not recorded.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildDeploymentEnvironment(buildDir: string): string | undefined;
|
|
20
|
+
/** Runs W5, W10 and W9-params. */
|
|
21
|
+
export declare function runCostGuardRules(input: SiteSystemRenderedInput, options?: CostGuardRuleOptions): Promise<readonly SiteSystemFinding[]>;
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/guards/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAMhE,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACxF,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,eAAe,EAAE,yBAAyB,EAAE,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,KAAK,kBAAkB,EAAE,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAEnN,MAAM,WAAW,oBAAoB;IACnC,kFAAkF;IAClF,QAAQ,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC;IACpB,kDAAkD;IAClD,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;CAClC;AAMD,wBAAgB,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAO/E;AA0BD,wGAAwG;AACxG,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGhE;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAS/E;AAUD,kCAAkC;AAClC,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,uBAAuB,EAAE,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAwBjJ"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cost guards (release 3.13.0): small checks so cost work never harms a site. W5, the origin region recorded and
|
|
3
|
+
* reviewed; W10, robots.txt and the firewall leave the search and answer crawlers open; W9-params, estate pages never
|
|
4
|
+
* read searchParams. The browser half of W9 (URL variants) is a journey (`defineBrandlabSiteSystemTests`).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { originRegionProblems, sydneyToday } from "../../../origin-region.js";
|
|
9
|
+
import { parseProjectHosting, readSiteSystemProjectConfig } from "../../config.js";
|
|
10
|
+
import { fail, pass } from "../enquiry/common.js";
|
|
11
|
+
import { checkW9Params } from "./w9-params.js";
|
|
12
|
+
import { BRANDLAB_SEARCH_CRAWLERS } from "@brandlab-digital/site-core/site";
|
|
13
|
+
import { checkW10, parseRobotsTxt, robotsAllows } from "./w10-crawlers.js";
|
|
14
|
+
export { checkW9Params, dynamicRoutePageFiles, searchParamLines } from "./w9-params.js";
|
|
15
|
+
export { checkW10, firewallRules, parseRobotsTxt, robotsAllows, robotsGroupsFor, userAgentConditionMatches } from "./w10-crawlers.js";
|
|
16
|
+
function readOptional(path) {
|
|
17
|
+
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
18
|
+
}
|
|
19
|
+
export function checkW5(projectRoot, today) {
|
|
20
|
+
const config = readOptional(join(projectRoot, "brandlab.config.ts"));
|
|
21
|
+
const hosting = config === null ? undefined : parseProjectHosting(config);
|
|
22
|
+
const problems = originRegionProblems({ hosting, vercelJson: readOptional(join(projectRoot, "vercel.json")), today });
|
|
23
|
+
if (problems.length > 0)
|
|
24
|
+
return problems.map((problem) => fail("W5", problem.startsWith("vercel.json") ? "vercel.json" : "brandlab.config.ts", problem));
|
|
25
|
+
const regions = hosting?.state === "literal" ? JSON.stringify(hosting.value?.originRegion ?? "syd1") : '"syd1"';
|
|
26
|
+
return [pass("W5", `vercel.json runs the server functions in the recorded origin region (${regions}).`)];
|
|
27
|
+
}
|
|
28
|
+
async function readRobots(input, fetcher) {
|
|
29
|
+
if (input.baseUrl) {
|
|
30
|
+
const url = `${input.baseUrl.replace(/\/+$/, "")}/robots.txt`;
|
|
31
|
+
try {
|
|
32
|
+
const response = await (fetcher ?? globalThis.fetch)(url, { headers: { accept: "text/plain" }, redirect: "follow", signal: AbortSignal.timeout(30_000) });
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
return { text: null, problem: `GET /robots.txt answered ${response.status}.` };
|
|
35
|
+
const closed = /\bnoindex\b/i.test(response.headers.get("x-robots-tag") ?? "");
|
|
36
|
+
return { text: await response.text(), closed };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return { text: null, problem: `GET /robots.txt failed (${error instanceof Error ? error.message : String(error)}).` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const buildDir = input.buildDir ?? join(input.projectRoot, ".next");
|
|
43
|
+
const file = join(buildDir, "server", "app", "robots.txt.body");
|
|
44
|
+
const text = readOptional(file);
|
|
45
|
+
if (text === null) {
|
|
46
|
+
return { text: null, problem: "The build has no prerendered robots.txt (.next/server/app/robots.txt.body); run next build, or pass --base-url to read it from the running site." };
|
|
47
|
+
}
|
|
48
|
+
// The robots helper closes every deployment but production on purpose: a build made for a Preview (VERCEL_ENV
|
|
49
|
+
// preview) is not judged. A production build, or one whose environment is not recorded, still is.
|
|
50
|
+
const environment = buildDeploymentEnvironment(buildDir);
|
|
51
|
+
return { text, closed: environment !== undefined && environment !== "production" && robotsClosedToEveryCrawler(text) };
|
|
52
|
+
}
|
|
53
|
+
/** True when robots.txt shuts every search and answer crawler out of the whole site (`Disallow: /`). */
|
|
54
|
+
export function robotsClosedToEveryCrawler(text) {
|
|
55
|
+
const groups = parseRobotsTxt(text);
|
|
56
|
+
return BRANDLAB_SEARCH_CRAWLERS.every((crawler) => !robotsAllows(groups, crawler, "/"));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The deployment environment a Next build was made for: the `NEXT_PUBLIC_VERCEL_ENV` that defineBrandlabNextConfig
|
|
60
|
+
* inlines (from VERCEL_ENV), as the build records it in required-server-files.json. Undefined when it is not recorded.
|
|
61
|
+
*/
|
|
62
|
+
export function buildDeploymentEnvironment(buildDir) {
|
|
63
|
+
const manifest = readOptional(join(buildDir, "required-server-files.json"));
|
|
64
|
+
if (manifest === null)
|
|
65
|
+
return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(manifest).config?.env?.NEXT_PUBLIC_VERCEL_ENV;
|
|
68
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function representativePaths(projectRoot) {
|
|
75
|
+
try {
|
|
76
|
+
return ["/", ...Object.values(readSiteSystemProjectConfig(projectRoot).representativeRoutes)];
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return ["/"];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Runs W5, W10 and W9-params. */
|
|
83
|
+
export async function runCostGuardRules(input, options = {}) {
|
|
84
|
+
const today = sydneyToday(options.now);
|
|
85
|
+
const vercelText = readOptional(join(input.projectRoot, "vercel.json"));
|
|
86
|
+
let vercelJson = null;
|
|
87
|
+
try {
|
|
88
|
+
vercelJson = vercelText === null ? null : JSON.parse(vercelText);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
vercelJson = null;
|
|
92
|
+
}
|
|
93
|
+
const config = readOptional(join(input.projectRoot, "brandlab.config.ts"));
|
|
94
|
+
const hosting = config === null ? undefined : parseProjectHosting(config);
|
|
95
|
+
const robots = await readRobots(input, options.fetch);
|
|
96
|
+
return [
|
|
97
|
+
...checkW5(input.projectRoot, today),
|
|
98
|
+
...checkW10({
|
|
99
|
+
robotsTxt: robots.text,
|
|
100
|
+
...(robots.problem ? { robotsProblem: robots.problem } : {}),
|
|
101
|
+
closedDeployment: robots.closed === true,
|
|
102
|
+
paths: [...new Set(representativePaths(input.projectRoot))],
|
|
103
|
+
vercelJson,
|
|
104
|
+
hosting: hosting?.state === "literal" ? hosting.value : undefined,
|
|
105
|
+
}),
|
|
106
|
+
...checkW9Params(input.projectRoot),
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/site-system/rules/guards/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The cost guards (release 3.13.0): small checks so cost work never harms a site. W5, the origin region recorded and\n * reviewed; W10, robots.txt and the firewall leave the search and answer crawlers open; W9-params, estate pages never\n * read searchParams. The browser half of W9 (URL variants) is a journey (`defineBrandlabSiteSystemTests`).\n */\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { originRegionProblems, sydneyToday } from \"../../../origin-region.js\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { parseProjectHosting, readSiteSystemProjectConfig } from \"../../config.js\";\nimport type { SiteSystemFetch } from \"../../samples.js\";\nimport type { SiteSystemRenderedInput } from \"../../runners.js\";\nimport { fail, pass } from \"../enquiry/common.js\";\nimport { checkW9Params } from \"./w9-params.js\";\nimport { BRANDLAB_SEARCH_CRAWLERS } from \"@brandlab-digital/site-core/site\";\nimport { checkW10, parseRobotsTxt, robotsAllows } from \"./w10-crawlers.js\";\n\nexport { checkW9Params, dynamicRoutePageFiles, searchParamLines } from \"./w9-params.js\";\nexport { checkW10, firewallRules, parseRobotsTxt, robotsAllows, robotsGroupsFor, userAgentConditionMatches, type FirewallRule, type RobotsGroup, type UserAgentCondition, type W10Input } from \"./w10-crawlers.js\";\n\nexport interface CostGuardRuleOptions {\n /** The moment W5 judges a decision's reviewBy on (in Sydney). Defaults to now. */\n readonly now?: Date;\n /** Fetches robots.txt with --base-url (tests). */\n readonly fetch?: SiteSystemFetch;\n}\n\nfunction readOptional(path: string): string | null {\n return existsSync(path) ? readFileSync(path, \"utf8\") : null;\n}\n\nexport function checkW5(projectRoot: string, today: string): SiteSystemFinding[] {\n const config = readOptional(join(projectRoot, \"brandlab.config.ts\"));\n const hosting = config === null ? undefined : parseProjectHosting(config);\n const problems = originRegionProblems({ hosting, vercelJson: readOptional(join(projectRoot, \"vercel.json\")), today });\n if (problems.length > 0) return problems.map((problem) => fail(\"W5\", problem.startsWith(\"vercel.json\") ? \"vercel.json\" : \"brandlab.config.ts\", problem));\n const regions = hosting?.state === \"literal\" ? JSON.stringify((hosting.value as { originRegion?: unknown } | undefined)?.originRegion ?? \"syd1\") : '\"syd1\"';\n return [pass(\"W5\", `vercel.json runs the server functions in the recorded origin region (${regions}).`)];\n}\n\nasync function readRobots(input: SiteSystemRenderedInput, fetcher: SiteSystemFetch | undefined): Promise<{ text: string | null; problem?: string; closed?: boolean }> {\n if (input.baseUrl) {\n const url = `${input.baseUrl.replace(/\\/+$/, \"\")}/robots.txt`;\n try {\n const response = await (fetcher ?? (globalThis.fetch as unknown as SiteSystemFetch))(url, { headers: { accept: \"text/plain\" }, redirect: \"follow\", signal: AbortSignal.timeout(30_000) });\n if (!response.ok) return { text: null, problem: `GET /robots.txt answered ${response.status}.` };\n const closed = /\\bnoindex\\b/i.test(response.headers.get(\"x-robots-tag\") ?? \"\");\n return { text: await response.text(), closed };\n } catch (error) {\n return { text: null, problem: `GET /robots.txt failed (${error instanceof Error ? error.message : String(error)}).` };\n }\n }\n const buildDir = input.buildDir ?? join(input.projectRoot, \".next\");\n const file = join(buildDir, \"server\", \"app\", \"robots.txt.body\");\n const text = readOptional(file);\n if (text === null) {\n return { text: null, problem: \"The build has no prerendered robots.txt (.next/server/app/robots.txt.body); run next build, or pass --base-url to read it from the running site.\" };\n }\n // The robots helper closes every deployment but production on purpose: a build made for a Preview (VERCEL_ENV\n // preview) is not judged. A production build, or one whose environment is not recorded, still is.\n const environment = buildDeploymentEnvironment(buildDir);\n return { text, closed: environment !== undefined && environment !== \"production\" && robotsClosedToEveryCrawler(text) };\n}\n\n/** True when robots.txt shuts every search and answer crawler out of the whole site (`Disallow: /`). */\nexport function robotsClosedToEveryCrawler(text: string): boolean {\n const groups = parseRobotsTxt(text);\n return BRANDLAB_SEARCH_CRAWLERS.every((crawler) => !robotsAllows(groups, crawler, \"/\"));\n}\n\n/**\n * The deployment environment a Next build was made for: the `NEXT_PUBLIC_VERCEL_ENV` that defineBrandlabNextConfig\n * inlines (from VERCEL_ENV), as the build records it in required-server-files.json. Undefined when it is not recorded.\n */\nexport function buildDeploymentEnvironment(buildDir: string): string | undefined {\n const manifest = readOptional(join(buildDir, \"required-server-files.json\"));\n if (manifest === null) return undefined;\n try {\n const value = (JSON.parse(manifest) as { config?: { env?: Record<string, unknown> } }).config?.env?.NEXT_PUBLIC_VERCEL_ENV;\n return typeof value === \"string\" && value.trim() ? value.trim() : undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction representativePaths(projectRoot: string): string[] {\n try {\n return [\"/\", ...Object.values(readSiteSystemProjectConfig(projectRoot).representativeRoutes)];\n } catch {\n return [\"/\"];\n }\n}\n\n/** Runs W5, W10 and W9-params. */\nexport async function runCostGuardRules(input: SiteSystemRenderedInput, options: CostGuardRuleOptions = {}): Promise<readonly SiteSystemFinding[]> {\n const today = sydneyToday(options.now);\n const vercelText = readOptional(join(input.projectRoot, \"vercel.json\"));\n let vercelJson: unknown = null;\n try {\n vercelJson = vercelText === null ? null : JSON.parse(vercelText);\n } catch {\n vercelJson = null;\n }\n const config = readOptional(join(input.projectRoot, \"brandlab.config.ts\"));\n const hosting = config === null ? undefined : parseProjectHosting(config);\n const robots = await readRobots(input, options.fetch);\n return [\n ...checkW5(input.projectRoot, today),\n ...checkW10({\n robotsTxt: robots.text,\n ...(robots.problem ? { robotsProblem: robots.problem } : {}),\n closedDeployment: robots.closed === true,\n paths: [...new Set(representativePaths(input.projectRoot))],\n vercelJson,\n hosting: hosting?.state === \"literal\" ? hosting.value : undefined,\n }),\n ...checkW9Params(input.projectRoot),\n ];\n}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
2
|
+
export interface RobotsGroup {
|
|
3
|
+
/** The user-agent tokens, lower-cased. */
|
|
4
|
+
readonly agents: readonly string[];
|
|
5
|
+
readonly rules: readonly {
|
|
6
|
+
readonly allow: boolean;
|
|
7
|
+
readonly path: string;
|
|
8
|
+
}[];
|
|
9
|
+
readonly crawlDelay: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** robots.txt as groups (RFC 9309): consecutive user-agent lines, then their rules. Comments and unknown lines are skipped. */
|
|
12
|
+
export declare function parseRobotsTxt(text: string): RobotsGroup[];
|
|
13
|
+
/** The groups that apply to a crawler: those naming its token, or else the `*` groups. */
|
|
14
|
+
export declare function robotsGroupsFor(groups: readonly RobotsGroup[], crawler: string): RobotsGroup[];
|
|
15
|
+
/** Whether a crawler may fetch `path` under these groups: the longest matching rule wins, and Allow wins a tie. */
|
|
16
|
+
export declare function robotsAllows(groups: readonly RobotsGroup[], crawler: string, path: string): boolean;
|
|
17
|
+
/** A user-agent condition from a firewall rule, in the forms Vercel and the brandlab.config record write. */
|
|
18
|
+
export type UserAgentCondition = {
|
|
19
|
+
readonly match: "contains" | "equals" | "regex";
|
|
20
|
+
readonly value: string;
|
|
21
|
+
} | {
|
|
22
|
+
readonly eq?: string;
|
|
23
|
+
readonly neq?: string;
|
|
24
|
+
readonly inc?: readonly string[];
|
|
25
|
+
readonly ninc?: readonly string[];
|
|
26
|
+
readonly pre?: string;
|
|
27
|
+
readonly suf?: string;
|
|
28
|
+
readonly re?: string;
|
|
29
|
+
};
|
|
30
|
+
/** Whether a user-agent condition matches the crawler (case-insensitive, so a doubtful rule is flagged). */
|
|
31
|
+
export declare function userAgentConditionMatches(condition: UserAgentCondition | string, crawler: string): boolean;
|
|
32
|
+
export interface FirewallRule {
|
|
33
|
+
/** Where the rule is recorded, for messages: `vercel.json routes[2]` or `hosting.firewall "block-scrapers"`. */
|
|
34
|
+
readonly source: string;
|
|
35
|
+
readonly action: string;
|
|
36
|
+
readonly userAgent: UserAgentCondition | string;
|
|
37
|
+
}
|
|
38
|
+
/** The firewall rules that match on the user agent: vercel.json `routes` with `mitigate`, and `hosting.firewall`. */
|
|
39
|
+
export declare function firewallRules(vercelJson: unknown, hosting: unknown): FirewallRule[];
|
|
40
|
+
export interface W10Input {
|
|
41
|
+
/** robots.txt text, or null when it could not be read; `robotsProblem` says why. */
|
|
42
|
+
readonly robotsTxt: string | null;
|
|
43
|
+
readonly robotsProblem?: string;
|
|
44
|
+
/**
|
|
45
|
+
* True when the robots.txt came from a Preview, closed on purpose: a running deployment that answers noindex, or a
|
|
46
|
+
* build made for a non-production environment whose robots.txt disallows everything.
|
|
47
|
+
*/
|
|
48
|
+
readonly closedDeployment?: boolean;
|
|
49
|
+
/** Paths each crawler must be able to fetch: home and the representative routes. */
|
|
50
|
+
readonly paths: readonly string[];
|
|
51
|
+
readonly vercelJson: unknown;
|
|
52
|
+
readonly hosting: unknown;
|
|
53
|
+
}
|
|
54
|
+
export declare function checkW10(input: W10Input): SiteSystemFinding[];
|
|
55
|
+
//# sourceMappingURL=w10-crawlers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"w10-crawlers.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/guards/w10-crawlers.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGzD,MAAM,WAAW,WAAW;IAC1B,0CAA0C;IAC1C,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,SAAS;QAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9E,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,+HAA+H;AAC/H,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CA6B1D;AAED,0FAA0F;AAC1F,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,EAAE,CAI9F;AAQD,mHAAmH;AACnH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAUnG;AAED,6GAA6G;AAC7G,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC3E;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAe7L,4GAA4G;AAC5G,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,kBAAkB,GAAG,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAoB1G;AAED,MAAM,WAAW,YAAY;IAC3B,gHAAgH;IAChH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,kBAAkB,GAAG,MAAM,CAAC;CACjD;AAED,qHAAqH;AACrH,wBAAgB,aAAa,CAAC,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,YAAY,EAAE,CAuBnF;AAID,MAAM,WAAW,QAAQ;IACvB,oFAAoF;IACpF,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IACpC,oFAAoF;IACpF,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,iBAAiB,EAAE,CA+B7D"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W10, the crawler guard (3.13.0): robots.txt and the recorded firewall rules never block a search or answer crawler
|
|
3
|
+
* (site-core `BRANDLAB_SEARCH_CRAWLERS`), and robots.txt gives none of them a Crawl-delay. The robots.txt is the
|
|
4
|
+
* built one (or the running site's with --base-url); the firewall rules are vercel.json `routes` with `mitigate` and
|
|
5
|
+
* the rules recorded in brandlab.config.ts `hosting.firewall`.
|
|
6
|
+
*/
|
|
7
|
+
import { BRANDLAB_SEARCH_CRAWLERS } from "@brandlab-digital/site-core/site";
|
|
8
|
+
import { fail, notApplicable, pass } from "../enquiry/common.js";
|
|
9
|
+
/** robots.txt as groups (RFC 9309): consecutive user-agent lines, then their rules. Comments and unknown lines are skipped. */
|
|
10
|
+
export function parseRobotsTxt(text) {
|
|
11
|
+
const groups = [];
|
|
12
|
+
let current = null;
|
|
13
|
+
let collectingAgents = false;
|
|
14
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
15
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
16
|
+
const colon = line.indexOf(":");
|
|
17
|
+
if (colon === -1)
|
|
18
|
+
continue;
|
|
19
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
20
|
+
const value = line.slice(colon + 1).trim();
|
|
21
|
+
if (key === "user-agent") {
|
|
22
|
+
if (!collectingAgents || !current) {
|
|
23
|
+
current = { agents: [], rules: [], crawlDelay: null };
|
|
24
|
+
groups.push(current);
|
|
25
|
+
}
|
|
26
|
+
current.agents.push(value.toLowerCase());
|
|
27
|
+
collectingAgents = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!current)
|
|
31
|
+
continue;
|
|
32
|
+
collectingAgents = false;
|
|
33
|
+
if (key === "allow" || key === "disallow") {
|
|
34
|
+
// An empty Disallow allows everything; it adds no rule.
|
|
35
|
+
if (value)
|
|
36
|
+
current.rules.push({ allow: key === "allow", path: value });
|
|
37
|
+
}
|
|
38
|
+
else if (key === "crawl-delay") {
|
|
39
|
+
current.crawlDelay = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return groups;
|
|
43
|
+
}
|
|
44
|
+
/** The groups that apply to a crawler: those naming its token, or else the `*` groups. */
|
|
45
|
+
export function robotsGroupsFor(groups, crawler) {
|
|
46
|
+
const token = crawler.toLowerCase();
|
|
47
|
+
const named = groups.filter((group) => group.agents.includes(token));
|
|
48
|
+
return named.length > 0 ? named : groups.filter((group) => group.agents.includes("*"));
|
|
49
|
+
}
|
|
50
|
+
function patternMatches(pattern, path) {
|
|
51
|
+
const anchored = pattern.endsWith("$");
|
|
52
|
+
const body = (anchored ? pattern.slice(0, -1) : pattern).split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
53
|
+
return new RegExp(`^${body}${anchored ? "$" : ""}`).test(path);
|
|
54
|
+
}
|
|
55
|
+
/** Whether a crawler may fetch `path` under these groups: the longest matching rule wins, and Allow wins a tie. */
|
|
56
|
+
export function robotsAllows(groups, crawler, path) {
|
|
57
|
+
let best = null;
|
|
58
|
+
for (const group of robotsGroupsFor(groups, crawler)) {
|
|
59
|
+
for (const rule of group.rules) {
|
|
60
|
+
if (!patternMatches(rule.path, path))
|
|
61
|
+
continue;
|
|
62
|
+
const length = rule.path.length;
|
|
63
|
+
if (!best || length > best.length || (length === best.length && rule.allow))
|
|
64
|
+
best = { allow: rule.allow, length };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return best ? best.allow : true;
|
|
68
|
+
}
|
|
69
|
+
/** The user agents a crawler sends, as a rule would see them: its bare token and a typical full string. */
|
|
70
|
+
function crawlerUserAgents(crawler) {
|
|
71
|
+
return [crawler, `Mozilla/5.0 (compatible; ${crawler}/1.0)`];
|
|
72
|
+
}
|
|
73
|
+
function safeRegExp(source) {
|
|
74
|
+
try {
|
|
75
|
+
return new RegExp(source, "i");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Whether a user-agent condition matches the crawler (case-insensitive, so a doubtful rule is flagged). */
|
|
82
|
+
export function userAgentConditionMatches(condition, crawler) {
|
|
83
|
+
return crawlerUserAgents(crawler).some((agent) => {
|
|
84
|
+
const ua = agent.toLowerCase();
|
|
85
|
+
if (typeof condition === "string")
|
|
86
|
+
return safeRegExp(condition)?.test(agent) ?? false;
|
|
87
|
+
if ("match" in condition) {
|
|
88
|
+
const value = condition.value.toLowerCase();
|
|
89
|
+
if (condition.match === "contains")
|
|
90
|
+
return ua.includes(value);
|
|
91
|
+
if (condition.match === "equals")
|
|
92
|
+
return ua === value;
|
|
93
|
+
return safeRegExp(condition.value)?.test(agent) ?? false;
|
|
94
|
+
}
|
|
95
|
+
const checks = [];
|
|
96
|
+
if (condition.eq !== undefined)
|
|
97
|
+
checks.push(ua === condition.eq.toLowerCase());
|
|
98
|
+
if (condition.neq !== undefined)
|
|
99
|
+
checks.push(ua !== condition.neq.toLowerCase());
|
|
100
|
+
if (condition.inc !== undefined)
|
|
101
|
+
checks.push(condition.inc.some((value) => ua === value.toLowerCase()));
|
|
102
|
+
if (condition.ninc !== undefined)
|
|
103
|
+
checks.push(!condition.ninc.some((value) => ua === value.toLowerCase()));
|
|
104
|
+
if (condition.pre !== undefined)
|
|
105
|
+
checks.push(ua.startsWith(condition.pre.toLowerCase()));
|
|
106
|
+
if (condition.suf !== undefined)
|
|
107
|
+
checks.push(ua.endsWith(condition.suf.toLowerCase()));
|
|
108
|
+
if (condition.re !== undefined)
|
|
109
|
+
checks.push(safeRegExp(condition.re)?.test(agent) ?? false);
|
|
110
|
+
return checks.length > 0 && checks.every(Boolean);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** The firewall rules that match on the user agent: vercel.json `routes` with `mitigate`, and `hosting.firewall`. */
|
|
114
|
+
export function firewallRules(vercelJson, hosting) {
|
|
115
|
+
const rules = [];
|
|
116
|
+
const routes = isRecord(vercelJson) && Array.isArray(vercelJson.routes) ? vercelJson.routes : [];
|
|
117
|
+
routes.forEach((route, index) => {
|
|
118
|
+
if (!isRecord(route) || !isRecord(route.mitigate) || typeof route.mitigate.action !== "string")
|
|
119
|
+
return;
|
|
120
|
+
const has = Array.isArray(route.has) ? route.has : [];
|
|
121
|
+
for (const condition of has) {
|
|
122
|
+
if (!isRecord(condition) || condition.type !== "header" || String(condition.key).toLowerCase() !== "user-agent")
|
|
123
|
+
continue;
|
|
124
|
+
const value = condition.value;
|
|
125
|
+
if (typeof value === "string" || isRecord(value)) {
|
|
126
|
+
rules.push({ source: `vercel.json routes[${index}]`, action: route.mitigate.action, userAgent: value });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const recorded = isRecord(hosting) && Array.isArray(hosting.firewall) ? hosting.firewall : [];
|
|
131
|
+
for (const rule of recorded) {
|
|
132
|
+
if (!isRecord(rule) || !isRecord(rule.userAgent) || typeof rule.action !== "string")
|
|
133
|
+
continue;
|
|
134
|
+
const { match, value } = rule.userAgent;
|
|
135
|
+
if ((match === "contains" || match === "equals" || match === "regex") && typeof value === "string") {
|
|
136
|
+
rules.push({ source: `hosting.firewall "${String(rule.id)}"`, action: rule.action, userAgent: { match, value } });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return rules;
|
|
140
|
+
}
|
|
141
|
+
const BLOCKING_ACTIONS = new Set(["deny", "challenge"]);
|
|
142
|
+
export function checkW10(input) {
|
|
143
|
+
const findings = [];
|
|
144
|
+
const crawlers = BRANDLAB_SEARCH_CRAWLERS;
|
|
145
|
+
if (input.closedDeployment) {
|
|
146
|
+
findings.push(notApplicable("W10", "robots.txt came from a Preview deployment or a build made for one, which stays closed to every crawler; W10 reads the production build (VERCEL_ENV=production next build, or --base-url on production)."));
|
|
147
|
+
}
|
|
148
|
+
else if (input.robotsTxt === null) {
|
|
149
|
+
findings.push(fail("W10", "/robots.txt", input.robotsProblem ?? "robots.txt could not be read, so the crawler guard could not check it."));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const groups = parseRobotsTxt(input.robotsTxt);
|
|
153
|
+
for (const crawler of crawlers) {
|
|
154
|
+
const blocked = input.paths.filter((path) => !robotsAllows(groups, crawler, path));
|
|
155
|
+
if (blocked.length > 0) {
|
|
156
|
+
findings.push(fail("W10", "/robots.txt", `robots.txt blocks ${crawler} from ${blocked.join(", ")}; search and answer crawlers stay allowed (no blanket robots rule).`));
|
|
157
|
+
}
|
|
158
|
+
const delay = robotsGroupsFor(groups, crawler).find((group) => group.crawlDelay !== null);
|
|
159
|
+
if (delay) {
|
|
160
|
+
findings.push(fail("W10", "/robots.txt", `robots.txt gives ${crawler} a Crawl-delay of ${delay.crawlDelay}; Google ignores it and Bing honours it, so it only slows Bing indexing. Keep one only with a recorded decision and a Romie-owned W10 exception.`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const rule of firewallRules(input.vercelJson, input.hosting)) {
|
|
165
|
+
const hit = crawlers.filter((crawler) => userAgentConditionMatches(rule.userAgent, crawler));
|
|
166
|
+
if (hit.length === 0)
|
|
167
|
+
continue;
|
|
168
|
+
if (BLOCKING_ACTIONS.has(rule.action)) {
|
|
169
|
+
findings.push(fail("W10", rule.source.startsWith("vercel.json") ? "vercel.json" : "brandlab.config.ts", `The firewall rule ${rule.source} (${rule.action}) matches ${hit.join(", ")}; search and answer crawlers must not be denied or challenged.`));
|
|
170
|
+
}
|
|
171
|
+
else if (rule.action === "rate-limit") {
|
|
172
|
+
findings.push({ checkId: "W10", status: "pass", severity: "warning", target: "brandlab.config.ts", message: `The firewall rule ${rule.source} rate-limits ${hit.join(", ")}; check it never stops their normal crawl.` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (findings.some((finding) => finding.status === "fail" || finding.status === "not-applicable"))
|
|
176
|
+
return findings;
|
|
177
|
+
return [...findings, pass("W10", `robots.txt and the recorded firewall rules leave ${crawlers.join(", ")} open on ${input.paths.join(", ")}, with no Crawl-delay.`)];
|
|
178
|
+
}
|
|
179
|
+
function isRecord(value) {
|
|
180
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=w10-crawlers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"w10-crawlers.js","sourceRoot":"","sources":["../../../../src/site-system/rules/guards/w10-crawlers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * W10, the crawler guard (3.13.0): robots.txt and the recorded firewall rules never block a search or answer crawler\n * (site-core `BRANDLAB_SEARCH_CRAWLERS`), and robots.txt gives none of them a Crawl-delay. The robots.txt is the\n * built one (or the running site's with --base-url); the firewall rules are vercel.json `routes` with `mitigate` and\n * the rules recorded in brandlab.config.ts `hosting.firewall`.\n */\nimport { BRANDLAB_SEARCH_CRAWLERS, type BrandlabSearchCrawler } from \"@brandlab-digital/site-core/site\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, pass } from \"../enquiry/common.js\";\n\nexport interface RobotsGroup {\n /** The user-agent tokens, lower-cased. */\n readonly agents: readonly string[];\n readonly rules: readonly { readonly allow: boolean; readonly path: string }[];\n readonly crawlDelay: string | null;\n}\n\n/** robots.txt as groups (RFC 9309): consecutive user-agent lines, then their rules. Comments and unknown lines are skipped. */\nexport function parseRobotsTxt(text: string): RobotsGroup[] {\n const groups: { agents: string[]; rules: { allow: boolean; path: string }[]; crawlDelay: string | null }[] = [];\n let current: (typeof groups)[number] | null = null;\n let collectingAgents = false;\n for (const raw of text.split(/\\r?\\n/)) {\n const line = raw.replace(/#.*$/, \"\").trim();\n const colon = line.indexOf(\":\");\n if (colon === -1) continue;\n const key = line.slice(0, colon).trim().toLowerCase();\n const value = line.slice(colon + 1).trim();\n if (key === \"user-agent\") {\n if (!collectingAgents || !current) {\n current = { agents: [], rules: [], crawlDelay: null };\n groups.push(current);\n }\n current.agents.push(value.toLowerCase());\n collectingAgents = true;\n continue;\n }\n if (!current) continue;\n collectingAgents = false;\n if (key === \"allow\" || key === \"disallow\") {\n // An empty Disallow allows everything; it adds no rule.\n if (value) current.rules.push({ allow: key === \"allow\", path: value });\n } else if (key === \"crawl-delay\") {\n current.crawlDelay = value;\n }\n }\n return groups;\n}\n\n/** The groups that apply to a crawler: those naming its token, or else the `*` groups. */\nexport function robotsGroupsFor(groups: readonly RobotsGroup[], crawler: string): RobotsGroup[] {\n const token = crawler.toLowerCase();\n const named = groups.filter((group) => group.agents.includes(token));\n return named.length > 0 ? named : groups.filter((group) => group.agents.includes(\"*\"));\n}\n\nfunction patternMatches(pattern: string, path: string): boolean {\n const anchored = pattern.endsWith(\"$\");\n const body = (anchored ? pattern.slice(0, -1) : pattern).split(\"*\").map((part) => part.replace(/[.+?^${}()|[\\]\\\\]/g, \"\\\\$&\")).join(\".*\");\n return new RegExp(`^${body}${anchored ? \"$\" : \"\"}`).test(path);\n}\n\n/** Whether a crawler may fetch `path` under these groups: the longest matching rule wins, and Allow wins a tie. */\nexport function robotsAllows(groups: readonly RobotsGroup[], crawler: string, path: string): boolean {\n let best: { allow: boolean; length: number } | null = null;\n for (const group of robotsGroupsFor(groups, crawler)) {\n for (const rule of group.rules) {\n if (!patternMatches(rule.path, path)) continue;\n const length = rule.path.length;\n if (!best || length > best.length || (length === best.length && rule.allow)) best = { allow: rule.allow, length };\n }\n }\n return best ? best.allow : true;\n}\n\n/** A user-agent condition from a firewall rule, in the forms Vercel and the brandlab.config record write. */\nexport type UserAgentCondition =\n | { readonly match: \"contains\" | \"equals\" | \"regex\"; readonly value: string }\n | { readonly eq?: string; readonly neq?: string; readonly inc?: readonly string[]; readonly ninc?: readonly string[]; readonly pre?: string; readonly suf?: string; readonly re?: string };\n\n/** The user agents a crawler sends, as a rule would see them: its bare token and a typical full string. */\nfunction crawlerUserAgents(crawler: string): string[] {\n return [crawler, `Mozilla/5.0 (compatible; ${crawler}/1.0)`];\n}\n\nfunction safeRegExp(source: string): RegExp | null {\n try {\n return new RegExp(source, \"i\");\n } catch {\n return null;\n }\n}\n\n/** Whether a user-agent condition matches the crawler (case-insensitive, so a doubtful rule is flagged). */\nexport function userAgentConditionMatches(condition: UserAgentCondition | string, crawler: string): boolean {\n return crawlerUserAgents(crawler).some((agent) => {\n const ua = agent.toLowerCase();\n if (typeof condition === \"string\") return safeRegExp(condition)?.test(agent) ?? false;\n if (\"match\" in condition) {\n const value = condition.value.toLowerCase();\n if (condition.match === \"contains\") return ua.includes(value);\n if (condition.match === \"equals\") return ua === value;\n return safeRegExp(condition.value)?.test(agent) ?? false;\n }\n const checks: boolean[] = [];\n if (condition.eq !== undefined) checks.push(ua === condition.eq.toLowerCase());\n if (condition.neq !== undefined) checks.push(ua !== condition.neq.toLowerCase());\n if (condition.inc !== undefined) checks.push(condition.inc.some((value) => ua === value.toLowerCase()));\n if (condition.ninc !== undefined) checks.push(!condition.ninc.some((value) => ua === value.toLowerCase()));\n if (condition.pre !== undefined) checks.push(ua.startsWith(condition.pre.toLowerCase()));\n if (condition.suf !== undefined) checks.push(ua.endsWith(condition.suf.toLowerCase()));\n if (condition.re !== undefined) checks.push(safeRegExp(condition.re)?.test(agent) ?? false);\n return checks.length > 0 && checks.every(Boolean);\n });\n}\n\nexport interface FirewallRule {\n /** Where the rule is recorded, for messages: `vercel.json routes[2]` or `hosting.firewall \"block-scrapers\"`. */\n readonly source: string;\n readonly action: string;\n readonly userAgent: UserAgentCondition | string;\n}\n\n/** The firewall rules that match on the user agent: vercel.json `routes` with `mitigate`, and `hosting.firewall`. */\nexport function firewallRules(vercelJson: unknown, hosting: unknown): FirewallRule[] {\n const rules: FirewallRule[] = [];\n const routes = isRecord(vercelJson) && Array.isArray(vercelJson.routes) ? vercelJson.routes : [];\n routes.forEach((route: unknown, index) => {\n if (!isRecord(route) || !isRecord(route.mitigate) || typeof route.mitigate.action !== \"string\") return;\n const has = Array.isArray(route.has) ? route.has : [];\n for (const condition of has) {\n if (!isRecord(condition) || condition.type !== \"header\" || String(condition.key).toLowerCase() !== \"user-agent\") continue;\n const value = condition.value;\n if (typeof value === \"string\" || isRecord(value)) {\n rules.push({ source: `vercel.json routes[${index}]`, action: route.mitigate.action, userAgent: value as UserAgentCondition | string });\n }\n }\n });\n const recorded = isRecord(hosting) && Array.isArray(hosting.firewall) ? hosting.firewall : [];\n for (const rule of recorded) {\n if (!isRecord(rule) || !isRecord(rule.userAgent) || typeof rule.action !== \"string\") continue;\n const { match, value } = rule.userAgent;\n if ((match === \"contains\" || match === \"equals\" || match === \"regex\") && typeof value === \"string\") {\n rules.push({ source: `hosting.firewall \"${String(rule.id)}\"`, action: rule.action, userAgent: { match, value } });\n }\n }\n return rules;\n}\n\nconst BLOCKING_ACTIONS = new Set([\"deny\", \"challenge\"]);\n\nexport interface W10Input {\n /** robots.txt text, or null when it could not be read; `robotsProblem` says why. */\n readonly robotsTxt: string | null;\n readonly robotsProblem?: string;\n /**\n * True when the robots.txt came from a Preview, closed on purpose: a running deployment that answers noindex, or a\n * build made for a non-production environment whose robots.txt disallows everything.\n */\n readonly closedDeployment?: boolean;\n /** Paths each crawler must be able to fetch: home and the representative routes. */\n readonly paths: readonly string[];\n readonly vercelJson: unknown;\n readonly hosting: unknown;\n}\n\nexport function checkW10(input: W10Input): SiteSystemFinding[] {\n const findings: SiteSystemFinding[] = [];\n const crawlers: readonly BrandlabSearchCrawler[] = BRANDLAB_SEARCH_CRAWLERS;\n if (input.closedDeployment) {\n findings.push(notApplicable(\"W10\", \"robots.txt came from a Preview deployment or a build made for one, which stays closed to every crawler; W10 reads the production build (VERCEL_ENV=production next build, or --base-url on production).\"));\n } else if (input.robotsTxt === null) {\n findings.push(fail(\"W10\", \"/robots.txt\", input.robotsProblem ?? \"robots.txt could not be read, so the crawler guard could not check it.\"));\n } else {\n const groups = parseRobotsTxt(input.robotsTxt);\n for (const crawler of crawlers) {\n const blocked = input.paths.filter((path) => !robotsAllows(groups, crawler, path));\n if (blocked.length > 0) {\n findings.push(fail(\"W10\", \"/robots.txt\", `robots.txt blocks ${crawler} from ${blocked.join(\", \")}; search and answer crawlers stay allowed (no blanket robots rule).`));\n }\n const delay = robotsGroupsFor(groups, crawler).find((group) => group.crawlDelay !== null);\n if (delay) {\n findings.push(fail(\"W10\", \"/robots.txt\", `robots.txt gives ${crawler} a Crawl-delay of ${delay.crawlDelay}; Google ignores it and Bing honours it, so it only slows Bing indexing. Keep one only with a recorded decision and a Romie-owned W10 exception.`));\n }\n }\n }\n for (const rule of firewallRules(input.vercelJson, input.hosting)) {\n const hit = crawlers.filter((crawler) => userAgentConditionMatches(rule.userAgent, crawler));\n if (hit.length === 0) continue;\n if (BLOCKING_ACTIONS.has(rule.action)) {\n findings.push(fail(\"W10\", rule.source.startsWith(\"vercel.json\") ? \"vercel.json\" : \"brandlab.config.ts\", `The firewall rule ${rule.source} (${rule.action}) matches ${hit.join(\", \")}; search and answer crawlers must not be denied or challenged.`));\n } else if (rule.action === \"rate-limit\") {\n findings.push({ checkId: \"W10\", status: \"pass\", severity: \"warning\", target: \"brandlab.config.ts\", message: `The firewall rule ${rule.source} rate-limits ${hit.join(\", \")}; check it never stops their normal crawl.` });\n }\n }\n if (findings.some((finding) => finding.status === \"fail\" || finding.status === \"not-applicable\")) return findings;\n return [...findings, pass(\"W10\", `robots.txt and the recorded firewall rules leave ${crawlers.join(\", \")} open on ${input.paths.join(\", \")}, with no Crawl-delay.`)];\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return Boolean(value) && typeof value === \"object\" && !Array.isArray(value);\n}\n"]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { SiteSystemFinding } from "../../checks.js";
|
|
3
|
+
/** The page files under a dynamic segment, relative to the project, sorted. */
|
|
4
|
+
export declare function dynamicRoutePageFiles(projectRoot: string): string[] | null;
|
|
5
|
+
/** True when the module's directive prologue says `'use client'`. */
|
|
6
|
+
export declare function isClientModule(source: ts.SourceFile): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Where a source file names `searchParams` or `useSearchParams` in code (not in comments or strings), by 1-based line.
|
|
9
|
+
* `useSearchParams` in a `'use client'` module is the client-side reading the rule recommends, so it does not count
|
|
10
|
+
* there; the `searchParams` page prop always does.
|
|
11
|
+
*/
|
|
12
|
+
export declare function searchParamReads(fileName: string, text: string): {
|
|
13
|
+
line: number;
|
|
14
|
+
name: string;
|
|
15
|
+
}[];
|
|
16
|
+
/** The 1-based lines `searchParamReads` reports. */
|
|
17
|
+
export declare function searchParamLines(fileName: string, text: string): number[];
|
|
18
|
+
export declare function checkW9Params(projectRoot: string): SiteSystemFinding[];
|
|
19
|
+
//# sourceMappingURL=w9-params.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"w9-params.d.ts","sourceRoot":"","sources":["../../../../src/site-system/rules/guards/w9-params.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,MAAM,YAAY,CAAC;AAC5B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAMzD,+EAA+E;AAC/E,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAgB1E;AAED,qEAAqE;AACrE,wBAAgB,cAAc,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,GAAG,OAAO,CAM7D;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAajG;AAED,oDAAoD;AACpD,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEzE;AAED,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAkBtE"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W9-params (3.13.0): a page under a dynamic route (`[slug]`, `[...slug]`) never reads `searchParams`. Reading it
|
|
3
|
+
* makes the page render per request, so every tracking query (`?utm_source=`, `?gclid=`) becomes an origin render and
|
|
4
|
+
* a separate cache entry instead of the prerendered page. A source scan of the page files under the app directory.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import ts from "typescript";
|
|
9
|
+
import { fail, notApplicable, pass } from "../enquiry/common.js";
|
|
10
|
+
const PAGE_FILE = /^page\.(?:tsx|ts|jsx|js)$/;
|
|
11
|
+
const SEARCH_PARAM_NAMES = new Set(["searchParams", "useSearchParams"]);
|
|
12
|
+
/** The page files under a dynamic segment, relative to the project, sorted. */
|
|
13
|
+
export function dynamicRoutePageFiles(projectRoot) {
|
|
14
|
+
const appDir = ["src/app", "app"].map((path) => join(projectRoot, path)).find((path) => existsSync(path));
|
|
15
|
+
if (!appDir)
|
|
16
|
+
return null;
|
|
17
|
+
const files = [];
|
|
18
|
+
const visit = (directory, dynamic) => {
|
|
19
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
if (entry.name === "node_modules" || entry.name.startsWith("_"))
|
|
22
|
+
continue;
|
|
23
|
+
visit(join(directory, entry.name), dynamic || /^\[.+\]$/.test(entry.name));
|
|
24
|
+
}
|
|
25
|
+
else if (dynamic && entry.isFile() && PAGE_FILE.test(entry.name)) {
|
|
26
|
+
files.push(relative(projectRoot, join(directory, entry.name)));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
visit(appDir, false);
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
/** True when the module's directive prologue says `'use client'`. */
|
|
34
|
+
export function isClientModule(source) {
|
|
35
|
+
for (const statement of source.statements) {
|
|
36
|
+
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression))
|
|
37
|
+
return false;
|
|
38
|
+
if (statement.expression.text === "use client")
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Where a source file names `searchParams` or `useSearchParams` in code (not in comments or strings), by 1-based line.
|
|
45
|
+
* `useSearchParams` in a `'use client'` module is the client-side reading the rule recommends, so it does not count
|
|
46
|
+
* there; the `searchParams` page prop always does.
|
|
47
|
+
*/
|
|
48
|
+
export function searchParamReads(fileName, text) {
|
|
49
|
+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
50
|
+
const client = isClientModule(source);
|
|
51
|
+
const reads = new Map();
|
|
52
|
+
const visit = (node) => {
|
|
53
|
+
if (ts.isIdentifier(node) && SEARCH_PARAM_NAMES.has(node.text) && !(client && node.text === "useSearchParams")) {
|
|
54
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
55
|
+
reads.set(`${line}:${node.text}`, { line, name: node.text });
|
|
56
|
+
}
|
|
57
|
+
ts.forEachChild(node, visit);
|
|
58
|
+
};
|
|
59
|
+
visit(source);
|
|
60
|
+
return [...reads.values()].sort((left, right) => left.line - right.line || left.name.localeCompare(right.name));
|
|
61
|
+
}
|
|
62
|
+
/** The 1-based lines `searchParamReads` reports. */
|
|
63
|
+
export function searchParamLines(fileName, text) {
|
|
64
|
+
return [...new Set(searchParamReads(fileName, text).map((read) => read.line))];
|
|
65
|
+
}
|
|
66
|
+
export function checkW9Params(projectRoot) {
|
|
67
|
+
const files = dynamicRoutePageFiles(projectRoot);
|
|
68
|
+
if (files === null)
|
|
69
|
+
return [notApplicable("W9-params", "The project has no app directory to scan.")];
|
|
70
|
+
if (files.length === 0)
|
|
71
|
+
return [notApplicable("W9-params", "The project has no page under a dynamic route.")];
|
|
72
|
+
const failures = files.flatMap((file) => {
|
|
73
|
+
const reads = searchParamReads(file, readFileSync(join(projectRoot, file), "utf8"));
|
|
74
|
+
if (reads.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
const names = [...new Set(reads.map((read) => read.name))].join(" and ");
|
|
77
|
+
const lines = [...new Set(reads.map((read) => read.line))];
|
|
78
|
+
return [
|
|
79
|
+
fail("W9-params", file, `${file} reads ${names} (line ${lines.join(", ")}): a page under a dynamic route that reads the query renders at the origin for every tracking link (?utm_source=, ?gclid=) instead of serving its prerendered copy. Read the query in a 'use client' component with useSearchParams, inside a Suspense boundary, or move the page out of the dynamic route.`),
|
|
80
|
+
];
|
|
81
|
+
});
|
|
82
|
+
return failures.length > 0 ? failures : [pass("W9-params", `None of the ${files.length} pages under a dynamic route reads searchParams.`)];
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=w9-params.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"w9-params.js","sourceRoot":"","sources":["../../../../src/site-system/rules/guards/w9-params.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * W9-params (3.13.0): a page under a dynamic route (`[slug]`, `[...slug]`) never reads `searchParams`. Reading it\n * makes the page render per request, so every tracking query (`?utm_source=`, `?gclid=`) becomes an origin render and\n * a separate cache entry instead of the prerendered page. A source scan of the page files under the app directory.\n */\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { join, relative } from \"node:path\";\nimport ts from \"typescript\";\nimport type { SiteSystemFinding } from \"../../checks.js\";\nimport { fail, notApplicable, pass } from \"../enquiry/common.js\";\n\nconst PAGE_FILE = /^page\\.(?:tsx|ts|jsx|js)$/;\nconst SEARCH_PARAM_NAMES = new Set([\"searchParams\", \"useSearchParams\"]);\n\n/** The page files under a dynamic segment, relative to the project, sorted. */\nexport function dynamicRoutePageFiles(projectRoot: string): string[] | null {\n const appDir = [\"src/app\", \"app\"].map((path) => join(projectRoot, path)).find((path) => existsSync(path));\n if (!appDir) return null;\n const files: string[] = [];\n const visit = (directory: string, dynamic: boolean) => {\n for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {\n if (entry.isDirectory()) {\n if (entry.name === \"node_modules\" || entry.name.startsWith(\"_\")) continue;\n visit(join(directory, entry.name), dynamic || /^\\[.+\\]$/.test(entry.name));\n } else if (dynamic && entry.isFile() && PAGE_FILE.test(entry.name)) {\n files.push(relative(projectRoot, join(directory, entry.name)));\n }\n }\n };\n visit(appDir, false);\n return files;\n}\n\n/** True when the module's directive prologue says `'use client'`. */\nexport function isClientModule(source: ts.SourceFile): boolean {\n for (const statement of source.statements) {\n if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) return false;\n if (statement.expression.text === \"use client\") return true;\n }\n return false;\n}\n\n/**\n * Where a source file names `searchParams` or `useSearchParams` in code (not in comments or strings), by 1-based line.\n * `useSearchParams` in a `'use client'` module is the client-side reading the rule recommends, so it does not count\n * there; the `searchParams` page prop always does.\n */\nexport function searchParamReads(fileName: string, text: string): { line: number; name: string }[] {\n const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, fileName.endsWith(\"x\") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);\n const client = isClientModule(source);\n const reads = new Map<string, { line: number; name: string }>();\n const visit = (node: ts.Node) => {\n if (ts.isIdentifier(node) && SEARCH_PARAM_NAMES.has(node.text) && !(client && node.text === \"useSearchParams\")) {\n const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;\n reads.set(`${line}:${node.text}`, { line, name: node.text });\n }\n ts.forEachChild(node, visit);\n };\n visit(source);\n return [...reads.values()].sort((left, right) => left.line - right.line || left.name.localeCompare(right.name));\n}\n\n/** The 1-based lines `searchParamReads` reports. */\nexport function searchParamLines(fileName: string, text: string): number[] {\n return [...new Set(searchParamReads(fileName, text).map((read) => read.line))];\n}\n\nexport function checkW9Params(projectRoot: string): SiteSystemFinding[] {\n const files = dynamicRoutePageFiles(projectRoot);\n if (files === null) return [notApplicable(\"W9-params\", \"The project has no app directory to scan.\")];\n if (files.length === 0) return [notApplicable(\"W9-params\", \"The project has no page under a dynamic route.\")];\n const failures = files.flatMap((file) => {\n const reads = searchParamReads(file, readFileSync(join(projectRoot, file), \"utf8\"));\n if (reads.length === 0) return [];\n const names = [...new Set(reads.map((read) => read.name))].join(\" and \");\n const lines = [...new Set(reads.map((read) => read.line))];\n return [\n fail(\n \"W9-params\",\n file,\n `${file} reads ${names} (line ${lines.join(\", \")}): a page under a dynamic route that reads the query renders at the origin for every tracking link (?utm_source=, ?gclid=) instead of serving its prerendered copy. Read the query in a 'use client' component with useSearchParams, inside a Suspense boundary, or move the page out of the dynamic route.`,\n ),\n ];\n });\n return failures.length > 0 ? failures : [pass(\"W9-params\", `None of the ${files.length} pages under a dynamic route reads searchParams.`)];\n}\n"]}
|
|
@@ -2,5 +2,6 @@ import { type BrandlabSiteSystemTestOptions } from './site-system/journeys/plan.
|
|
|
2
2
|
export { SITE_SYSTEM_DESKTOP_VIEWPORT, SITE_SYSTEM_DESKTOP_WIDTHS, SITE_SYSTEM_JOURNEY_IDS, SITE_SYSTEM_PHONE_VIEWPORT, SITE_SYSTEM_QUOTE_NAME, planBrandlabSiteSystemJourneys, siteSystemJourneyTitle, siteSystemJourneysFor, type BrandlabSiteSystemTestOptions, type SiteSystemActionOrder, type SiteSystemJourneyId, type SiteSystemJourneyPlan, } from './site-system/journeys/plan.js';
|
|
3
3
|
export { HOVER_OPEN_WINDOW_MS, MOUSE_OUT_CLOSE_WINDOW_MS } from './site-system/journeys/menu.js';
|
|
4
4
|
export { SITE_SYSTEM_PHONE_PLACEMENTS } from './site-system/journeys/page-helpers.js';
|
|
5
|
+
export { W9_CACHE_HEADERS, W9_TRACKING_QUERY, W9_UNKNOWN_SLUG, notFoundVariantProblems, trackingVariantProblems, urlVariants, type W9Response, type W9Variants, } from './site-system/journeys/variants.js';
|
|
5
6
|
export declare function defineBrandlabSiteSystemTests(options: BrandlabSiteSystemTestOptions): void;
|
|
6
7
|
//# sourceMappingURL=site-system-e2e.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-system-e2e.d.ts","sourceRoot":"","sources":["../src/site-system-e2e.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"site-system-e2e.d.ts","sourceRoot":"","sources":["../src/site-system-e2e.ts"],"names":[],"mappings":"AA4BA,OAAO,EAAkC,KAAK,6BAA6B,EAAE,MAAM,gCAAgC,CAAC;AAKpH,OAAO,EACL,4BAA4B,EAC5B,0BAA0B,EAC1B,uBAAuB,EACvB,0BAA0B,EAC1B,sBAAsB,EACtB,8BAA8B,EAC9B,sBAAsB,EACtB,qBAAqB,EACrB,KAAK,6BAA6B,EAClC,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,GAC3B,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AACjG,OAAO,EAAE,4BAA4B,EAAE,MAAM,wCAAwC,CAAC;AACtF,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,uBAAuB,EACvB,WAAW,EACX,KAAK,UAAU,EACf,KAAK,UAAU,GAChB,MAAM,oCAAoC,CAAC;AAI5C,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,6BAA6B,GAAG,IAAI,CAuB1F"}
|