完善官网 SEO 地基与精确路由
补充 robots、sitemap、SEO 元信息、结构化数据与首页语义内容 统一三套 Nginx 与 Pingora 的 62 条 SPA 路由和真实 404 行为 新增路由一致性检查、网关测试并同步技术文档与项目记忆
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const APP_PAGE_ROUTES_PATH = 'src/routing/appPageRoutes.ts';
|
||||
const APP_ROUTES_PATH = 'src/routing/appRoutes.tsx';
|
||||
const COMPATIBILITY_ROUTES = ['/creation/rpg/agent'];
|
||||
const NGINX_PATHS = [
|
||||
'deploy/nginx/genarrative.conf',
|
||||
'deploy/nginx/genarrative-dev-http.conf',
|
||||
'deploy/container/nginx.conf',
|
||||
];
|
||||
const SPA_BLOCK_START = '# BEGIN GENARRATIVE MAIN SPA ROUTES';
|
||||
const SPA_BLOCK_END = '# END GENARRATIVE MAIN SPA ROUTES';
|
||||
const UNKNOWN_ROUTE_SAMPLES = [
|
||||
'/unknown-root',
|
||||
'/creation/not-exist',
|
||||
'/runtime/not-exist',
|
||||
'/puzzle/not-exist',
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
function extractSourceBlock(source, pattern, label) {
|
||||
const match = source.match(pattern);
|
||||
if (!match) {
|
||||
fail(`${label} 未找到。`);
|
||||
return '';
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function collectExpectedMainSpaRoutes() {
|
||||
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
|
||||
const appRoutes = readFileSync(APP_ROUTES_PATH, 'utf8');
|
||||
const stageEntries = extractSourceBlock(
|
||||
appPageRoutes,
|
||||
/const STAGE_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u,
|
||||
`${APP_PAGE_ROUTES_PATH} STAGE_ROUTE_ENTRIES`,
|
||||
);
|
||||
const runtimeEntries = extractSourceBlock(
|
||||
appPageRoutes,
|
||||
/export const APP_RUNTIME_ROUTES[^=]*= \{([\s\S]*?)\n\};/u,
|
||||
`${APP_PAGE_ROUTES_PATH} APP_RUNTIME_ROUTES`,
|
||||
);
|
||||
|
||||
const routes = [
|
||||
...Array.from(
|
||||
stageEntries.matchAll(/\[\s*'[^']+'\s*,\s*'([^']+)'\s*\]/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...Array.from(
|
||||
runtimeEntries.matchAll(/'[^']+'\s*:\s*'([^']+)'/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...Array.from(
|
||||
appRoutes.matchAll(/normalizedPath === '([^']+)'/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...COMPATIBILITY_ROUTES,
|
||||
];
|
||||
|
||||
const uniqueRoutes = [...new Set(routes)].sort();
|
||||
if (uniqueRoutes.length === 0) {
|
||||
fail('未从前端路由源提取到主站 SPA 路由。');
|
||||
}
|
||||
for (const route of uniqueRoutes) {
|
||||
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route)) {
|
||||
fail(`前端路由源包含门禁暂不支持的路径格式: ${route}`);
|
||||
}
|
||||
}
|
||||
return uniqueRoutes;
|
||||
}
|
||||
|
||||
function compareRouteSets(actualRoutes, expectedRoutes, label) {
|
||||
const actual = new Set(actualRoutes);
|
||||
const expected = new Set(expectedRoutes);
|
||||
const missing = expectedRoutes.filter((route) => !actual.has(route));
|
||||
const extra = actualRoutes.filter((route) => !expected.has(route));
|
||||
if (missing.length > 0) {
|
||||
fail(`${label} 缺少 SPA 路由: ${missing.join(', ')}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`${label} 包含非当前路由: ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNginxRoutes(nginxPath, expectedRoutes) {
|
||||
const source = readFileSync(nginxPath, 'utf8');
|
||||
const blockStart = source.indexOf(SPA_BLOCK_START);
|
||||
const blockEnd = source.indexOf(SPA_BLOCK_END);
|
||||
if (blockStart < 0 || blockEnd <= blockStart) {
|
||||
fail(`${nginxPath} 缺少完整 SPA allowlist 标记。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = source.slice(blockStart, blockEnd + SPA_BLOCK_END.length);
|
||||
if (!/location\s+=\s+\/\s*\{/u.test(block)) {
|
||||
fail(`${nginxPath} SPA allowlist 缺少根路径精确 location。`);
|
||||
}
|
||||
if (!block.includes('try_files /index.html =404;')) {
|
||||
fail(`${nginxPath} 根路径没有精确回退 index.html。`);
|
||||
}
|
||||
if (!block.includes('try_files $uri /index.html =404;')) {
|
||||
fail(`${nginxPath} SPA allowlist 没有精确回退 index.html。`);
|
||||
}
|
||||
|
||||
const regexMatch = block.match(/location\s+~\*\s+"([^"]+)"\s*\{/u);
|
||||
if (!regexMatch) {
|
||||
fail(`${nginxPath} 缺少大小写不敏感的 SPA allowlist regex location。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nginxPattern = regexMatch[1];
|
||||
const alternativesMatch = nginxPattern.match(/^\^\/\(\?:(.+)\)\/\?\$$/u);
|
||||
if (!alternativesMatch) {
|
||||
fail(`${nginxPath} SPA allowlist 必须锚定完整路径并允许一个尾部斜杠。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredRoutes = [
|
||||
'/',
|
||||
...alternativesMatch[1].split('|').map((route) => `/${route}`),
|
||||
].sort();
|
||||
compareRouteSets(configuredRoutes, expectedRoutes, nginxPath);
|
||||
|
||||
const matcher = new RegExp(nginxPattern, 'iu');
|
||||
for (const route of expectedRoutes.filter((candidate) => candidate !== '/')) {
|
||||
if (!matcher.test(route)) {
|
||||
fail(`${nginxPath} SPA allowlist 未匹配完整路径: ${route}`);
|
||||
}
|
||||
if (!matcher.test(`${route.toUpperCase()}/`)) {
|
||||
fail(`${nginxPath} SPA allowlist 未允许大小写差异和尾部斜杠: ${route}`);
|
||||
}
|
||||
}
|
||||
for (const route of UNKNOWN_ROUTE_SAMPLES) {
|
||||
if (matcher.test(route) || matcher.test(`${route}/`)) {
|
||||
fail(`${nginxPath} SPA allowlist 错误接收未知路径: ${route}`);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length);
|
||||
if (!defaultLocation.includes('try_files $uri $uri/ =404;')) {
|
||||
fail(
|
||||
`${nginxPath} 未命中 SPA allowlist 的路径必须只读真实静态文件并返回 404。`,
|
||||
);
|
||||
}
|
||||
if (defaultLocation.includes('try_files $uri $uri/ /index.html;')) {
|
||||
fail(`${nginxPath} 默认 location 仍存在全路径 SPA fallback。`);
|
||||
}
|
||||
}
|
||||
|
||||
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
|
||||
|
||||
for (const nginxPath of NGINX_PATHS) {
|
||||
validateNginxRoutes(nginxPath, expectedMainSpaRoutes);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:nginx-spa-routes] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`,
|
||||
);
|
||||
@@ -568,15 +568,36 @@ async function runSmokeCases(
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/some/deep/link',
|
||||
'/creation/puzzle/result',
|
||||
200,
|
||||
'site-shell',
|
||||
'主站深链回退 index.html',
|
||||
'主站 allowlist 深链回退 index.html',
|
||||
{
|
||||
validate: (response) =>
|
||||
response.headers['cache-control'] === 'no-cache',
|
||||
},
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/CREATION/PUZZLE/RESULT/',
|
||||
200,
|
||||
'site-shell',
|
||||
'主站 allowlist 允许大小写差异和尾部斜杠',
|
||||
);
|
||||
for (const unknownPath of [
|
||||
'/some/deep/link',
|
||||
'/creation/not-exist',
|
||||
'/runtime/not-exist',
|
||||
'/puzzle/not-exist',
|
||||
]) {
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
unknownPath,
|
||||
404,
|
||||
'',
|
||||
`主站未知路径返回真实 404: ${unknownPath}`,
|
||||
);
|
||||
}
|
||||
await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', {
|
||||
validate: (response) => response.headers.location === '/admin/',
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs';
|
||||
|
||||
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
|
||||
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
|
||||
const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf';
|
||||
@@ -46,6 +48,11 @@ const REQUIRED_ROUTE_IDS = [
|
||||
'readyz_forbidden',
|
||||
'generated_assets_forbidden',
|
||||
'web_spa_fallback',
|
||||
'web_spa_case_trailing_slash',
|
||||
'web_unknown_path_exact',
|
||||
'creation_unknown_path_exact',
|
||||
'runtime_unknown_path_exact',
|
||||
'puzzle_unknown_path_exact',
|
||||
];
|
||||
|
||||
const files = {
|
||||
@@ -221,6 +228,9 @@ function validateRustTestUsesMatrix() {
|
||||
'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)',
|
||||
'protection_class_for_route(&route, &case.sample_path)',
|
||||
'fn matches_nginx_route_parity_matrix()',
|
||||
'fn is_main_spa_path(path: &str)',
|
||||
"path.strip_suffix('/')",
|
||||
'normalized.eq_ignore_ascii_case(candidate)',
|
||||
]) {
|
||||
if (!pingoraGatewaySource.includes(fragment)) {
|
||||
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
|
||||
@@ -228,8 +238,34 @@ function validateRustTestUsesMatrix() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateRustMainSpaRoutes() {
|
||||
const routeBlock = pingoraGatewaySource.match(
|
||||
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\n\];/u,
|
||||
);
|
||||
if (!routeBlock) {
|
||||
fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。');
|
||||
return;
|
||||
}
|
||||
|
||||
const rustRoutes = Array.from(
|
||||
routeBlock[1].matchAll(/"([^"]+)"/gu),
|
||||
(match) => match[1],
|
||||
).sort();
|
||||
const expected = new Set(expectedMainSpaRoutes);
|
||||
const actual = new Set(rustRoutes);
|
||||
const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route));
|
||||
const extra = rustRoutes.filter((route) => !expected.has(route));
|
||||
if (missing.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
validateMatrixShape();
|
||||
validateRustTestUsesMatrix();
|
||||
validateRustMainSpaRoutes();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:pingora-route-parity] FAILED');
|
||||
|
||||
Reference in New Issue
Block a user