diff --git a/package.json b/package.json index 58aa73e..b188c3b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "compress": "node scripts/compress.js", "security-check": "node scripts/security-check.js", "quality-check": "node scripts/quality-check.js", + "check:links": "node scripts/check-asset-links.js", "deploy-prepare": "node scripts/deploy-prepare.js", "deploy-analyze": "node scripts/deploy-analyze.js", "deploy-test": "node scripts/test-deploy-config.js" diff --git a/scripts/check-asset-links.js b/scripts/check-asset-links.js new file mode 100644 index 0000000..fed2a54 --- /dev/null +++ b/scripts/check-asset-links.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * 生产静态资源脱链检查 + * 扫描 HTML 中的本地 script/link/href/src,确认文件在仓库或 dist 中存在。 + * 禁止把不存在的路径发布到生产。 + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.join(__dirname, '..'); +const htmlRoots = [ + path.join(projectRoot, 'index.html'), + path.join(projectRoot, 'src', 'giffgaff', 'giffgaff_modular.html'), + path.join(projectRoot, 'src', 'simyo', 'simyo_modular.html') +]; + +const ATTR_RE = /\b(?:src|href)=["']([^"']+)["']/gi; +const SKIP_PREFIXES = [ + 'http://', + 'https://', + '//', + 'data:', + 'mailto:', + 'tel:', + 'javascript:', + '#', + 'blob:' +]; + +function shouldSkip(url) { + return SKIP_PREFIXES.some((p) => url.startsWith(p)); +} + +// Netlify redirects 中的“软路由”(无静态文件,但生产可访问) +const KNOWN_ROUTES = new Set(['/giffgaff', '/simyo']); + +function looksLikeStaticAsset(cleanPath) { + // 有扩展名的视为静态资源;无扩展名多为路由入口 + const base = path.basename(cleanPath); + return base.includes('.'); +} + +function resolveLocal(url) { + // 去掉 query/hash + const clean = url.split('?')[0].split('#')[0]; + if (!clean || clean.endsWith('/')) return null; + if (KNOWN_ROUTES.has(clean)) return null; + if (!looksLikeStaticAsset(clean)) return null; + + // 仅检查站点内绝对路径 + if (clean.startsWith('/')) { + const fromRoot = path.join(projectRoot, clean.slice(1)); + const fromDist = path.join(projectRoot, 'dist', clean.slice(1)); + return { clean, candidates: [fromRoot, fromDist] }; + } + + return null; +} + +function collectUrls(html) { + const urls = new Set(); + let match; + while ((match = ATTR_RE.exec(html)) !== null) { + urls.add(match[1]); + } + return [...urls]; +} + +function main() { + const missing = []; + let checked = 0; + + for (const htmlPath of htmlRoots) { + if (!fs.existsSync(htmlPath)) { + missing.push({ file: htmlPath, url: '(file missing)', reason: 'HTML 入口不存在' }); + continue; + } + + const html = fs.readFileSync(htmlPath, 'utf8'); + const urls = collectUrls(html); + const relHtml = path.relative(projectRoot, htmlPath); + + for (const url of urls) { + if (shouldSkip(url)) continue; + const resolved = resolveLocal(url); + if (!resolved) continue; + + checked++; + const exists = resolved.candidates.some((p) => fs.existsSync(p)); + if (!exists) { + missing.push({ + file: relHtml, + url: resolved.clean, + reason: '本地文件不存在(源码与 dist 均未找到)' + }); + } + } + } + + // 额外:构建产物中禁止残留 Webpack 路径提示(若 resource-hints 被挂载) + const forbiddenHints = [ + '/dist/js/main.js', + '/dist/js/vendors.js', + '/dist/css/design-system.css' + ]; + const resourceHintsPath = path.join(projectRoot, 'src', 'js', 'modules', 'resource-hints.js'); + if (fs.existsSync(resourceHintsPath)) { + const content = fs.readFileSync(resourceHintsPath, 'utf8'); + for (const bad of forbiddenHints) { + if (content.includes(bad)) { + missing.push({ + file: 'src/js/modules/resource-hints.js', + url: bad, + reason: '遗留 Webpack 产物路径,生产构建不会生成该文件' + }); + } + } + } + + console.log(`[check-asset-links] 已检查本地资源引用: ${checked}`); + + if (missing.length === 0) { + console.log('[check-asset-links] ✅ 无脱链'); + process.exit(0); + } + + console.error('[check-asset-links] ❌ 发现脱链/无效路径:'); + missing.forEach((m) => { + console.error(` - ${m.file}: ${m.url} (${m.reason})`); + }); + process.exit(1); +} + +main(); diff --git a/scripts/pre-push-check.js b/scripts/pre-push-check.js index 1ec52ed..c82da00 100644 --- a/scripts/pre-push-check.js +++ b/scripts/pre-push-check.js @@ -103,6 +103,11 @@ function buildSteps(mode) { { name: 'pre-commit 检查', args: ['run', 'precommit:check'] + }, + { + // 生产 HTML 本地资源脱链门禁(不改运行时,仅静态校验) + name: '生产资源脱链检查', + args: ['run', 'check:links'] } ]; diff --git a/scripts/quality-check.js b/scripts/quality-check.js index e89ff9c..9b9eca7 100644 --- a/scripts/quality-check.js +++ b/scripts/quality-check.js @@ -13,21 +13,30 @@ const projectRoot = path.join(__dirname, '..'); // 检查项配置 const checks = { - // 1. 语法检查 + // 1. 语法检查(对齐当前生产路径:build-static + Netlify Functions) syntaxCheck: { name: '语法检查', files: [ 'server.js', - 'webpack.config.js', + 'scripts/build-static.js', + 'scripts/transpile-dist-js.js', + 'scripts/check-asset-links.js', + 'src/js/middleware/validation.js', 'netlify/functions/_shared/middleware.js', + 'netlify/functions/_shared/cors.js', + 'netlify/functions/_shared/rate-limiter.js', 'netlify/functions/health.js', + 'netlify/functions/public-config.js', + 'netlify/functions/notifications.js', 'netlify/functions/giffgaff-graphql.js', 'netlify/functions/giffgaff-mfa-challenge.js', 'netlify/functions/giffgaff-mfa-validation.js', 'netlify/functions/giffgaff-sms-activate.js', 'netlify/functions/auto-activate-esim.js', 'netlify/functions/giffgaff-token-exchange.js', - 'netlify/functions/verify-cookie.js' + 'netlify/functions/verify-cookie.js', + 'netlify/edge-functions/bff-proxy.js', + 'netlify/edge-functions/markdown-negotiation.js' ] }, @@ -153,7 +162,7 @@ function runChecks() { BuildLogger.title('代码质量全面检查'); // 1. 语法检查 - BuildLogger.step(1, 4, checks.syntaxCheck.name); + BuildLogger.step(1, 5, checks.syntaxCheck.name); let syntaxPassed = 0; checks.syntaxCheck.files.forEach(file => { totalChecks++; @@ -169,7 +178,7 @@ function runChecks() { BuildLogger.progress(`${syntaxPassed}/${checks.syntaxCheck.files.length} 文件通过语法检查\n`); // 2. 环境变量检查 - BuildLogger.step(2, 4, checks.envVarCheck.name); + BuildLogger.step(2, 5, checks.envVarCheck.name); totalChecks++; const envIssues = checkEnvExample(); if (envIssues.length === 0) { @@ -182,7 +191,7 @@ function runChecks() { } // 3. 依赖检查 - BuildLogger.step(3, 4, checks.dependencyCheck.name); + BuildLogger.step(3, 5, checks.dependencyCheck.name); totalChecks++; const depIssues = checkPackageJson(); if (depIssues.length === 0) { @@ -195,7 +204,7 @@ function runChecks() { } // 4. 安全配置检查 - BuildLogger.step(4, 4, checks.securityCheck.name); + BuildLogger.step(4, 5, checks.securityCheck.name); // 检查弱密钥(排除安全检查代码中的引用) totalChecks++; @@ -213,6 +222,24 @@ function runChecks() { failedChecks++; } + // 5. 生产资源脱链检查(禁止发布不存在的静态路径) + BuildLogger.step(5, 5, '生产资源脱链检查'); + totalChecks++; + try { + execSync('node scripts/check-asset-links.js', { + cwd: projectRoot, + stdio: 'pipe', + encoding: 'utf8' + }); + BuildLogger.check(true, 'HTML 本地资源引用完整'); + passedChecks++; + } catch (error) { + BuildLogger.check(false, '发现脱链或无效路径'); + const output = `${error.stdout || ''}${error.stderr || ''}${error.message || ''}`; + output.split('\n').filter(Boolean).forEach((line) => BuildLogger.error(` ${line}`)); + failedChecks++; + } + // 统计报告 BuildLogger.title('\n检查结果汇总'); BuildLogger.log(`总检查项: ${totalChecks}`); diff --git a/src/js/modules/resource-hints.js b/src/js/modules/resource-hints.js index abfc7d5..1664422 100644 --- a/src/js/modules/resource-hints.js +++ b/src/js/modules/resource-hints.js @@ -13,14 +13,15 @@ class ResourceHintsManager { 'https://appapi.simyo.nl' ], dnsPrefetch: [], + // 生产构建为原生 ES 模块静态托管,禁止 preload 已废弃的 Webpack 产物路径 preload: { fonts: [], scripts: [ - { href: '/dist/js/main.js', as: 'script' }, - { href: '/dist/js/vendors.js', as: 'script' } + { href: '/src/js/home.js', as: 'script' }, + { href: '/src/js/bootstrap-footer.js', as: 'script' } ], styles: [ - { href: '/dist/css/design-system.css', as: 'style' } + { href: '/src/styles/design-system.css', as: 'style' } ], images: [] },