diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c290657c..a4fe19e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,5 +63,16 @@ jobs: - name: Run Playwright Smoke run: npm run test:e2e:smoke + - name: Run Route Width Screenshot Matrix + run: npm run test:e2e:route-width + + - name: Upload Route Width Screenshots + if: always() + uses: actions/upload-artifact@v6 + with: + name: route-width-screenshots + path: test-results/route-width + if-no-files-found: error + - name: Run PWA Smoke run: npm run test:e2e:pwa diff --git a/docs/quality/input-intent-taxonomy.md b/docs/quality/input-intent-taxonomy.md new file mode 100644 index 00000000..f53ccf8e --- /dev/null +++ b/docs/quality/input-intent-taxonomy.md @@ -0,0 +1,15 @@ +# Input intent taxonomy + +Tool input sizing uses five stable intents: + +| Intent | Use | +| --- | --- | +| `scalar` | Numbers, colors, flags, enum-like controls, and other compact values. | +| `shortText` | URLs, names, expressions, and short free-form text. | +| `payload` | User-authored JSON, CSV, tokens, logs, code, and multiline data. | +| `workbench` | Dense editor or split-pane work areas. | +| `generatedOutput` | Read-only generated text or code with copy/export actions. | + +`Input`, `Textarea`, Monaco wrappers, and `TextOutputPanel` emit `data-input-intent` at runtime. The create-tool scaffold declares a payload input, a workbench container, and generated output explicitly. + +`tests/guards/input-intent-taxonomy.test.ts` parses every TSX file under `src` to inventory shared controls plus raw `input`, `textarea`, and `select` elements. Representative routes must declare an explicit intent. Remaining hardcoded height tokens are frozen by a stable file/tag/token hash, so a new unclassified size fails CI without depending on source line numbers. diff --git a/package-lock.json b/package-lock.json index 6bed2294..7fc71256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "jq-wasm": "^1.1.0-jq-1.8.1", "js-beautify": "^1.15.4", "jsbarcode": "^3.12.3", + "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.575.0", @@ -9252,6 +9253,12 @@ "node": ">=6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", diff --git a/package.json b/package.json index fe923332..d9710c50 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "scripts": { "dev": "next dev", "analyze": "ANALYZE=true next build --webpack", - "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:llms-txt && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:i18n-qa && npm run check:types", + "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:llms-txt && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:route-width-inventory && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:i18n-qa && npm run check:types", "build:app": "next build", "build:post": "npm run check:canonical && npm run check:hreflang && npm run check:legacy-routes && npm run check:metadata-localization && npm run check:jsonld-structured-data && npm run check:performance-budget:report && npm run check:rendered-i18n-copy && npm run check:related-tools && npm run check:content-template:legacy-paths && npm run check:content-template && npm run check:content-template:quality && npm run check:content-template:uniqueness && npm run check:faq-schema && npm run check:content-template:zh-cn && npm run check:content-template:quality:zh-cn && npm run check:faq-schema:zh-cn && npm run check:content-template:zh-tw && npm run check:content-template:quality:zh-tw && npm run check:faq-schema:zh-tw && npm run check:content-template:ja && npm run check:content-template:quality:ja && npm run check:faq-schema:ja && npm run check:content-template:ko && npm run check:content-template:quality:ko && npm run check:faq-schema:ko && npm run check:content-template:de && npm run check:content-template:quality:de && npm run check:faq-schema:de && npm run check:content-template:fr && npm run check:content-template:quality:fr && npm run check:faq-schema:fr && npm run postprocess:export-html-lang && npm run check:export-html-lang && npm run postprocess:export-robots-meta && npm run check:export-robots-meta && npm run check:sitemap-export && npm run build:sw", "build": "npm run validate && npm run build:app && npm run build:post", @@ -60,6 +60,8 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e:smoke": "node scripts/e2e/run-playwright-smoke.js", + "test:e2e:first-load": "node scripts/e2e/run-playwright-smoke.js --first-load-only --first-load-artifacts", + "test:e2e:route-width": "node scripts/e2e/run-route-width-matrix.js", "test:e2e:pwa": "node scripts/e2e/run-playwright-smoke.js --pwa", "check:i18n": "node scripts/gates/check-i18n.js", "check:i18n-qa": "node scripts/gates/check-i18n-qa.js", @@ -79,6 +81,8 @@ "check:sitemap-lastmod": "node scripts/gates/check-sitemap-lastmod.js", "generate:ia-baseline": "node scripts/generators/generate-ia-baseline.js", "check:ia-stability": "node scripts/gates/check-ia-stability.js", + "generate:route-width-inventory": "node scripts/generators/generate-route-width-inventory.js", + "check:route-width-inventory": "node scripts/generators/generate-route-width-inventory.js --check", "check:analytics-taxonomy": "node scripts/gates/check-analytics-taxonomy.js", "check:privacy-copy": "node scripts/gates/check-privacy-copy.js", "check:security-headers": "node scripts/gates/check-security-headers-config.js", @@ -147,6 +151,7 @@ "jq-wasm": "^1.1.0-jq-1.8.1", "js-beautify": "^1.15.4", "jsbarcode": "^3.12.3", + "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.575.0", diff --git a/public/_redirects b/public/_redirects index 127da197..1f7c6eef 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,5 +1,142 @@ /security.txt /.well-known/security.txt 301 /en / 301 +/json-formatter /en/json-formatter 301 +/xml-formatter /en/xml-formatter 301 +/sql-formatter /en/sql-formatter 301 +/javascript-formatter /en/javascript-formatter 301 +/javascript-minifier /en/javascript-minifier 301 +/html-minifier /en/html-minifier 301 +/html-encoder-decoder /en/html-encoder-decoder 301 +/html-css-beautifier /en/html-css-beautifier 301 +/html-formatter /en/html-formatter 301 +/yaml-json-converter /en/yaml-json-converter 301 +/markdown-preview /en/markdown-preview 301 +/html-to-markdown /en/html-to-markdown 301 +/json-to-typescript /en/json-to-typescript 301 +/css-minifier /en/css-minifier 301 +/svg-optimizer /en/svg-optimizer 301 +/jsonpath-playground /en/jsonpath-playground 301 +/openapi-viewer /en/openapi-viewer 301 +/json-diff-viewer /en/json-diff-viewer 301 +/csv-json-converter /en/csv-json-converter 301 +/base64-encode-decode /en/base64-encode-decode 301 +/base-encoding-converter /en/base-encoding-converter 301 +/url-encode-decode /en/url-encode-decode 301 +/jwt-decoder /en/jwt-decoder 301 +/jwt-workbench /en/jwt-workbench 301 +/hash-generator /en/hash-generator 301 +/md5-generator /en/md5-generator 301 +/text-diff-checker /en/text-diff-checker 301 +/multiple-whitespace-remover /en/multiple-whitespace-remover 301 +/letter-counter /en/letter-counter 301 +/bionic-reading-converter /en/bionic-reading-converter 301 +/google-fonts-pair-finder /en/google-fonts-pair-finder 301 +/text-to-handwriting-converter /en/text-to-handwriting-converter 301 +/code-to-image-converter /en/code-to-image-converter 301 +/image-base64 /en/image-base64 301 +/unix-timestamp /en/unix-timestamp 301 +/uuid-generator /en/uuid-generator 301 +/lorem-ipsum /en/lorem-ipsum 301 +/password-generator /en/password-generator 301 +/color-converter /en/color-converter 301 +/react-native-shadow-generator /en/react-native-shadow-generator 301 +/ai-color-palette-generator /en/ai-color-palette-generator 301 +/color-mixer /en/color-mixer 301 +/color-shades-generator /en/color-shades-generator 301 +/image-average-color-finder /en/image-average-color-finder 301 +/image-caption-generator /en/image-caption-generator 301 +/image-color-extractor /en/image-color-extractor 301 +/image-color-picker /en/image-color-picker 301 +/image-cropper /en/image-cropper 301 +/image-filters /en/image-filters 301 +/instagram-filters /en/instagram-filters 301 +/instagram-post-generator /en/instagram-post-generator 301 +/instagram-story-generator /en/instagram-story-generator 301 +/open-graph-meta-generator /en/open-graph-meta-generator 301 +/tweet-generator /en/tweet-generator 301 +/tweet-to-image-converter /en/tweet-to-image-converter 301 +/twitter-ad-revenue-generator /en/twitter-ad-revenue-generator 301 +/instagram-photo-downloader /en/instagram-photo-downloader 301 +/vimeo-thumbnail-grabber /en/vimeo-thumbnail-grabber 301 +/youtube-thumbnail-grabber /en/youtube-thumbnail-grabber 301 +/image-resizer /en/image-resizer 301 +/photo-censor /en/photo-censor 301 +/scanned-pdf-converter /en/scanned-pdf-converter 301 +/svg-blob-generator /en/svg-blob-generator 301 +/svg-pattern-generator /en/svg-pattern-generator 301 +/svg-stroke-to-fill-converter /en/svg-stroke-to-fill-converter 301 +/svg-to-png-converter /en/svg-to-png-converter 301 +/css-background-pattern-generator /en/css-background-pattern-generator 301 +/css-border-radius-generator /en/css-border-radius-generator 301 +/css-box-shadow-generator /en/css-box-shadow-generator 301 +/css-checkbox-generator /en/css-checkbox-generator 301 +/css-clip-path-generator /en/css-clip-path-generator 301 +/css-cubic-bezier-generator /en/css-cubic-bezier-generator 301 +/css-glassmorphism-generator /en/css-glassmorphism-generator 301 +/css-gradient-generator /en/css-gradient-generator 301 +/css-loader-generator /en/css-loader-generator 301 +/css-switch-generator /en/css-switch-generator 301 +/css-text-glitch-effect-generator /en/css-text-glitch-effect-generator 301 +/css-triangle-generator /en/css-triangle-generator 301 +/qr-code-generator /en/qr-code-generator 301 +/barcode-generator /en/barcode-generator 301 +/fake-iban-generator /en/fake-iban-generator 301 +/list-randomizer /en/list-randomizer 301 +/ascii-art-generator /en/ascii-art-generator 301 +/env-parser /en/env-parser 301 +/id-generator /en/id-generator 301 +/regex-tester /en/regex-tester 301 +/regex-generator /en/regex-generator 301 +/crontab-generator /en/crontab-generator 301 +/user-agent-parser /en/user-agent-parser 301 +/cron-visualizer /en/cron-visualizer 301 +/http-status-codes /en/http-status-codes 301 +/chmod-calculator /en/chmod-calculator 301 +/cidr-subnet-calculator /en/cidr-subnet-calculator 301 +/url-parser /en/url-parser 301 +/certificate-decoder /en/certificate-decoder 301 +/http-request-builder /en/http-request-builder 301 +/curl-to-code /en/curl-to-code 301 +/ndjson-formatter /en/ndjson-formatter 301 +/jwt-verifier /en/jwt-verifier 301 +/slugify-case-converter /en/slugify-case-converter 301 +/invisible-characters-detector /en/invisible-characters-detector 301 +/robots-txt-tester /en/robots-txt-tester 301 +/csp-parser /en/csp-parser 301 +/csv-diff /en/csv-diff 301 +/header-diff /en/header-diff 301 +/security-header-analyzer /en/security-header-analyzer 301 +/totp-generator /en/totp-generator 301 +/openapi-mock /en/openapi-mock 301 +/docker-run-to-compose /en/docker-run-to-compose 301 +/local-log-parser /en/local-log-parser 301 +/jq-playground /en/jq-playground 301 +/log-scrubber /en/log-scrubber 301 +/gzip-brotli-lab /en/gzip-brotli-lab 301 +/yaml-merge-patch-explorer /en/yaml-merge-patch-explorer 301 +/yq-playground /en/yq-playground 301 +/structured-data-visualizer /en/structured-data-visualizer 301 +/har-viewer-sanitizer /en/har-viewer-sanitizer 301 +/pipeline-builder /en/pipeline-builder 301 +/saml-decoder /en/saml-decoder 301 +/asn1-der-inspector /en/asn1-der-inspector 301 +/hex-bytes-workbench /en/hex-bytes-workbench 301 +/unicode-inspector /en/unicode-inspector 301 +/public-key-jwk-helper /en/public-key-jwk-helper 301 +/json-schema-workbench /en/json-schema-workbench 301 +/openapi-diff /en/openapi-diff 301 +/graphql-workbench /en/graphql-workbench 301 +/messagepack-inspector /en/messagepack-inspector 301 +/seo-metadata-workbench /en/seo-metadata-workbench 301 +/devops-yaml-validator /en/devops-yaml-validator 301 +/oauth-jwks-workbench /en/oauth-jwks-workbench 301 +/image-privacy-workbench /en/image-privacy-workbench 301 +/cron-expression-generator /en/crontab-generator 301 +/tls-chain-inspector /en/certificate-decoder 301 +/csv-to-json /en/csv-json-converter 301 +/web-api /en/web-api-network 301 +/design-media /en/images-svg-css 301 +/convert-encode /en/all-tools 301 /en/cron-expression-generator /en/crontab-generator 301 /zh-CN/cron-expression-generator /zh-CN/crontab-generator 301 /zh-TW/cron-expression-generator /zh-TW/crontab-generator 301 @@ -14,6 +151,13 @@ /ko/tls-chain-inspector /ko/certificate-decoder 301 /de/tls-chain-inspector /de/certificate-decoder 301 /fr/tls-chain-inspector /fr/certificate-decoder 301 +/en/csv-to-json /en/csv-json-converter 301 +/zh-CN/csv-to-json /zh-CN/csv-json-converter 301 +/zh-TW/csv-to-json /zh-TW/csv-json-converter 301 +/ja/csv-to-json /ja/csv-json-converter 301 +/ko/csv-to-json /ko/csv-json-converter 301 +/de/csv-to-json /de/csv-json-converter 301 +/fr/csv-to-json /fr/csv-json-converter 301 /en/web-api /en/web-api-network 301 /zh-CN/web-api /zh-CN/web-api-network 301 /zh-TW/web-api /zh-TW/web-api-network 301 diff --git a/public/runtime/theme-manifest-bootstrap.js b/public/runtime/theme-manifest-bootstrap.js index 9b2410c8..c9a19e82 100644 --- a/public/runtime/theme-manifest-bootstrap.js +++ b/public/runtime/theme-manifest-bootstrap.js @@ -6,14 +6,25 @@ var activeLang = locales.indexOf(seg) >= 0 ? seg : "en"; document.documentElement.setAttribute("lang", activeLang); - var t = localStorage.getItem("theme"); + var t = null; + try { + t = localStorage.getItem("theme"); + } catch {} + if (t !== "light" && t !== "dark" && t !== "system") t = null; if (!t) { - var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/); - t = m ? m[1] : null; + try { + var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/); + t = m ? m[1] : null; + } catch {} } + if (t !== "light" && t !== "dark" && t !== "system") t = null; if (!t) t = "dark"; if (t === "system") { - t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + try { + t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } catch { + t = "dark"; + } } document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.add(t); diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index e263dc71..bb0d939f 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import { existsSync, readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import axe from "axe-core"; @@ -47,9 +47,103 @@ const AXE_REVIEW_ROUTES = [ "/en/trust-center", "/en/install-app", ]; +const INPUT_INTENT_AUDIT_ROUTES = [ + { route: "/en/qr-code-generator", requiredIntents: ["shortText", "scalar"] }, + { route: "/en/base64-encode-decode", requiredIntents: ["payload", "generatedOutput"] }, + { route: "/en/regex-tester", requiredIntents: ["scalar", "shortText", "payload"] }, + { route: "/en/json-formatter", requiredIntents: ["workbench", "payload"] }, + { route: "/en/csv-json-converter", requiredIntents: ["workbench", "payload", "generatedOutput"] }, + { route: "/en/jwt-decoder", requiredIntents: ["workbench", "payload", "generatedOutput"] }, + { route: "/en/pipeline-builder", requiredIntents: ["workbench", "shortText", "payload", "generatedOutput"] }, +]; +const INPUT_INTENT_AUDIT_VIEWPORTS = [ + { width: 390, height: 844, mobile: true }, + { width: 1280, height: 900, mobile: false }, +]; const ALL_TOOLS_FILTER_INTERACTION_BUDGET_MS = 2000; const ALL_TOOLS_MOBILE_SCROLL_BUDGET_MS = 3500; const ALL_TOOLS_MOBILE_MAX_FRAME_DELTA_MS = 500; +const FIRST_LOAD_CLS_BUDGET = 0.1; +const FIRST_LOAD_SETTLE_MS = 2_600; +const FIRST_LOAD_ARTIFACT_DIR = path.resolve(process.cwd(), "output/first-load-audit"); +const FIRST_LOAD_AUDIT_CASES = [ + { + id: "root-preferred-locale", + route: "/", + expectedPathname: "/", + expectedLang: "en", + theme: "dark", + colorScheme: "dark", + preferredLocale: "zh-CN", + }, + { + id: "qr-light", + route: "/en/qr-code-generator", + expectedPathname: "/en/qr-code-generator", + expectedLang: "en", + theme: "light", + colorScheme: "dark", + warmReload: true, + }, + { + id: "json-dark", + route: "/en/json-formatter", + expectedPathname: "/en/json-formatter", + expectedLang: "en", + theme: "dark", + colorScheme: "light", + }, + { + id: "all-tools-system", + route: "/en/all-tools", + expectedPathname: "/en/all-tools", + expectedLang: "en", + theme: "system", + colorScheme: "light", + }, + { + id: "all-tools-personalized", + route: "/en/all-tools", + expectedPathname: "/en/all-tools", + expectedLang: "en", + theme: "dark", + colorScheme: "dark", + storage: { + "byteflow:tools:favorites": JSON.stringify([ + { toolKey: "json_formatter", updatedAt: "2026-07-01T00:00:00.000Z" }, + { toolKey: "base64_encode_decode", updatedAt: "2026-07-01T00:00:00.000Z" }, + { toolKey: "uuid_generator", updatedAt: "2026-07-01T00:00:00.000Z" }, + ]), + "byteflow:tools:recent": JSON.stringify([ + { toolKey: "qr_code_generator", updatedAt: "2026-07-01T00:00:00.000Z" }, + { toolKey: "json_formatter", updatedAt: "2026-07-01T00:00:00.000Z" }, + ]), + }, + }, + { + id: "qr-zh-cn-system", + route: "/zh-CN/qr-code-generator", + expectedPathname: "/zh-CN/qr-code-generator", + expectedLang: "zh-CN", + theme: "system", + colorScheme: "dark", + warmReload: true, + }, +]; +const FIRST_LOAD_AUDIT_PROFILES = [ + { + id: "mobile-390x844", + viewport: { width: 390, height: 844 }, + isMobile: true, + caseIds: FIRST_LOAD_AUDIT_CASES.map((testCase) => testCase.id), + }, + { + id: "desktop-1280x800", + viewport: { width: 1280, height: 800 }, + isMobile: false, + caseIds: ["root-preferred-locale", "all-tools-system", "qr-zh-cn-system"], + }, +]; const GENERATED_TOOL_INDEX_PATH = path.resolve(process.cwd(), "src/generated/tool-index.json"); function getExpectedToolCount() { @@ -111,6 +205,9 @@ function parseArgs(argv) { baseUrl: "", skipServer: false, includePwa: false, + firstLoadOnly: false, + writeFirstLoadArtifacts: false, + inputIntentsOnly: false, }; for (const arg of argv) { @@ -124,6 +221,21 @@ function parseArgs(argv) { continue; } + if (arg === "--first-load-only") { + args.firstLoadOnly = true; + continue; + } + + if (arg === "--first-load-artifacts") { + args.writeFirstLoadArtifacts = true; + continue; + } + + if (arg === "--input-intents-only") { + args.inputIntentsOnly = true; + continue; + } + if (arg.startsWith("--port=")) { const parsed = Number(arg.slice("--port=".length)); if (Number.isFinite(parsed) && parsed > 0) { @@ -226,6 +338,363 @@ async function assertRouteRenders(context, baseUrl, route) { await page.close(); } +function expectedResolvedTheme(testCase) { + return testCase.theme === "system" ? testCase.colorScheme : testCase.theme; +} + +async function installFirstLoadInstrumentation(context, testCase) { + await context.addInitScript(({ preferredLocale, storage, theme }) => { + try { + localStorage.setItem("theme", theme); + if (preferredLocale) { + localStorage.setItem("byteflow:preferred-locale", preferredLocale); + } + for (const [key, value] of Object.entries(storage || {})) { + localStorage.setItem(key, value); + } + } catch { + // The audit still validates the default path when storage is unavailable. + } + + const audit = { + layoutShiftSupported: false, + shifts: [], + themeChanges: [], + }; + window.__byteflowFirstLoadAudit = audit; + + const readTheme = () => ({ + className: document.documentElement?.className || "", + colorScheme: document.documentElement?.style.colorScheme || "", + }); + const recordTheme = () => audit.themeChanges.push({ + startTime: performance.now(), + ...readTheme(), + }); + const observeThemeRoot = () => { + if (!document.documentElement) return false; + recordTheme(); + new MutationObserver(recordTheme).observe(document.documentElement, { + attributeFilter: ["class", "style"], + attributes: true, + }); + return true; + }; + if (!observeThemeRoot()) { + const rootObserver = new MutationObserver(() => { + if (observeThemeRoot()) rootObserver.disconnect(); + }); + rootObserver.observe(document, { childList: true, subtree: true }); + } + + const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || []; + audit.layoutShiftSupported = supportedEntryTypes.includes("layout-shift"); + if (!audit.layoutShiftSupported) return; + + const copyRect = (rect) => ({ + height: rect.height, + width: rect.width, + x: rect.x, + y: rect.y, + }); + const describeNode = (node) => { + if (!(node instanceof Element)) return "unknown"; + if (node.id) return `#${node.id}`; + for (const attribute of [ + "data-all-tools-personalization", + "data-all-tools-card", + "data-navbar-controls-footprint", + "data-navbar-language-footprint", + "data-navbar-theme-footprint", + "data-tool-global-actions", + ]) { + if (node.hasAttribute(attribute)) return `[${attribute}]`; + } + const classes = Array.from(node.classList).slice(0, 3).join("."); + return `${node.tagName.toLowerCase()}${classes ? `.${classes}` : ""}`; + }; + + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.hadRecentInput) continue; + audit.shifts.push({ + startTime: entry.startTime, + value: entry.value, + sources: (entry.sources || []).map((source) => ({ + currentRect: copyRect(source.currentRect), + node: describeNode(source.node), + previousRect: copyRect(source.previousRect), + })), + }); + } + }); + observer.observe({ type: "layout-shift", buffered: true }); + }, { + preferredLocale: testCase.preferredLocale || "", + storage: testCase.storage || {}, + theme: testCase.theme, + }); +} + +function calculateSessionWindowCls(entries) { + let currentScore = 0; + let currentWindowStart = 0; + let lastEntryTime = 0; + let maxScore = 0; + + for (const entry of [...entries].sort((left, right) => left.startTime - right.startTime)) { + const startsNewWindow = currentScore === 0 + || entry.startTime - lastEntryTime > 1_000 + || entry.startTime - currentWindowStart > 5_000; + if (startsNewWindow) { + currentScore = entry.value; + currentWindowStart = entry.startTime; + } else { + currentScore += entry.value; + } + lastEntryTime = entry.startTime; + maxScore = Math.max(maxScore, currentScore); + } + + return maxScore; +} + +async function capturePreHydrationState(browser, baseUrl, profile, testCase) { + const context = await browser.newContext({ + colorScheme: testCase.colorScheme, + isMobile: profile.isMobile, + serviceWorkers: "block", + viewport: profile.viewport, + }); + await installFirstLoadInstrumentation(context, testCase); + const page = await context.newPage(); + + try { + await page.route("**/*", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (request.resourceType() === "script" && pathname.startsWith("/_next/")) { + await route.abort(); + return; + } + await route.continue(); + }); + + const response = await page.goto(`${baseUrl}${testCase.route}`, { waitUntil: "domcontentloaded" }); + if (!response || !response.ok()) { + throw new Error(`Pre-hydration route ${testCase.route} failed with ${response?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.evaluate(() => document.fonts?.ready); + await page.waitForTimeout(100); + + return { + heading: (await page.locator("h1").first().textContent())?.replace(/\s+/g, " ").trim() || "", + screenshot: await page.screenshot({ animations: "disabled", fullPage: false }), + theme: await page.evaluate(() => ({ + className: document.documentElement.className, + colorScheme: document.documentElement.style.colorScheme, + })), + }; + } finally { + await context.close(); + } +} + +async function captureHydratedState(browser, baseUrl, profile, testCase) { + const context = await browser.newContext({ + colorScheme: testCase.colorScheme, + isMobile: profile.isMobile, + serviceWorkers: "block", + viewport: profile.viewport, + }); + await installFirstLoadInstrumentation(context, testCase); + const page = await context.newPage(); + const runtime = createRuntimeObserver(page, `First-load audit ${profile.id}/${testCase.id}`, baseUrl); + + try { + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await cdp.send("Network.emulateNetworkConditions", { + connectionType: "cellular4g", + downloadThroughput: (1.6 * 1024 * 1024) / 8, + latency: 100, + offline: false, + uploadThroughput: (750 * 1024) / 8, + }); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + + const response = await page.goto(`${baseUrl}${testCase.route}`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + if (!response || !response.ok()) { + throw new Error(`Hydrated route ${testCase.route} failed with ${response?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.waitForTimeout(FIRST_LOAD_SETTLE_MS); + await page.evaluate(() => document.fonts?.ready); + + const metrics = await page.evaluate(() => ({ + audit: window.__byteflowFirstLoadAudit, + firstContentfulPaint: performance.getEntriesByName("first-contentful-paint")[0]?.startTime ?? null, + heading: document.querySelector("h1")?.textContent?.replace(/\s+/g, " ").trim() || "", + lang: document.documentElement.lang, + pathname: window.location.pathname, + theme: { + className: document.documentElement.className, + colorScheme: document.documentElement.style.colorScheme, + }, + })); + const screenshot = await page.screenshot({ animations: "disabled", fullPage: false }); + runtime.assertClean(); + return { metrics, screenshot }; + } finally { + await context.close(); + } +} + +async function captureWarmReloadState(browser, baseUrl, profile, testCase) { + const context = await browser.newContext({ + colorScheme: testCase.colorScheme, + isMobile: profile.isMobile, + serviceWorkers: "block", + viewport: profile.viewport, + }); + await installFirstLoadInstrumentation(context, testCase); + const page = await context.newPage(); + const runtime = createRuntimeObserver(page, `Warm reload audit ${profile.id}/${testCase.id}`, baseUrl); + + try { + const initialResponse = await page.goto(`${baseUrl}${testCase.route}`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + if (!initialResponse || !initialResponse.ok()) { + throw new Error(`Warm reload seed ${testCase.route} failed with ${initialResponse?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.waitForTimeout(200); + + const cdp = await context.newCDPSession(page); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + + const response = await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); + if (!response || !response.ok()) { + throw new Error(`Warm reload ${testCase.route} failed with ${response?.status() || "no response"}.`); + } + await page.waitForSelector("main", { timeout: 30_000 }); + await page.waitForTimeout(FIRST_LOAD_SETTLE_MS); + await page.evaluate(() => document.fonts?.ready); + + const metrics = await page.evaluate(() => ({ + audit: window.__byteflowFirstLoadAudit, + firstContentfulPaint: performance.getEntriesByName("first-contentful-paint")[0]?.startTime ?? null, + heading: document.querySelector("h1")?.textContent?.replace(/\s+/g, " ").trim() || "", + lang: document.documentElement.lang, + pathname: window.location.pathname, + theme: { + className: document.documentElement.className, + colorScheme: document.documentElement.style.colorScheme, + }, + })); + const screenshot = await page.screenshot({ animations: "disabled", fullPage: false }); + runtime.assertClean(); + return { metrics, screenshot }; + } finally { + await context.close(); + } +} + +function assertFirstLoadResult(profile, testCase, before, after) { + const expectedTheme = expectedResolvedTheme(testCase); + const shifts = after.metrics.audit?.shifts || []; + const cls = calculateSessionWindowCls(shifts); + const label = `${profile.id}/${testCase.id}`; + const themeAtFirstContentfulPaint = [...(after.metrics.audit?.themeChanges || [])] + .reverse() + .find((entry) => entry.startTime <= after.metrics.firstContentfulPaint); + + if (!after.metrics.audit?.layoutShiftSupported) { + throw new Error(`${label} did not expose Chromium layout-shift entries.`); + } + if (cls >= FIRST_LOAD_CLS_BUDGET) { + throw new Error(`${label} CLS ${cls.toFixed(4)} exceeded the ${FIRST_LOAD_CLS_BUDGET} budget. Sources: ${JSON.stringify(shifts)}`); + } + if ( + after.metrics.firstContentfulPaint === null + || !themeAtFirstContentfulPaint?.className.split(/\s+/).includes(expectedTheme) + || themeAtFirstContentfulPaint.colorScheme !== expectedTheme + ) { + throw new Error(`${label} first contentful paint used ${JSON.stringify(themeAtFirstContentfulPaint)} instead of ${expectedTheme}.`); + } + if (!after.metrics.theme.className.split(/\s+/).includes(expectedTheme) || after.metrics.theme.colorScheme !== expectedTheme) { + throw new Error(`${label} settled theme used ${JSON.stringify(after.metrics.theme)} instead of ${expectedTheme}.`); + } + if (!before.theme.className.split(/\s+/).includes(expectedTheme) || before.theme.colorScheme !== expectedTheme) { + throw new Error(`${label} pre-hydration theme used ${JSON.stringify(before.theme)} instead of ${expectedTheme}.`); + } + if (after.metrics.pathname !== testCase.expectedPathname || after.metrics.lang !== testCase.expectedLang) { + throw new Error(`${label} settled at ${after.metrics.pathname} (${after.metrics.lang}), expected ${testCase.expectedPathname} (${testCase.expectedLang}).`); + } + if (!before.heading || before.heading !== after.metrics.heading) { + throw new Error(`${label} changed its first heading across hydration: ${JSON.stringify(before.heading)} -> ${JSON.stringify(after.metrics.heading)}.`); + } + if (before.screenshot.length < 2_000 || after.screenshot.length < 2_000) { + throw new Error(`${label} produced an unexpectedly empty first-load screenshot.`); + } + + return { + cls, + heading: after.metrics.heading, + lang: after.metrics.lang, + pathname: after.metrics.pathname, + shifts, + theme: after.metrics.theme, + themeAtFirstContentfulPaint, + }; +} + +async function writeFirstLoadCaseArtifacts(profile, testCase, before, after, warmAfter) { + await mkdir(FIRST_LOAD_ARTIFACT_DIR, { recursive: true }); + const prefix = `${profile.id}-${testCase.id}`; + await Promise.all([ + writeFile(path.join(FIRST_LOAD_ARTIFACT_DIR, `${prefix}-before-hydration.png`), before.screenshot), + writeFile(path.join(FIRST_LOAD_ARTIFACT_DIR, `${prefix}-after-hydration.png`), after.screenshot), + ...(warmAfter ? [writeFile(path.join(FIRST_LOAD_ARTIFACT_DIR, `${prefix}-warm-reload.png`), warmAfter.screenshot)] : []), + ]); +} + +async function assertFirstLoadStabilityMatrix(browser, baseUrl, { writeArtifacts = false } = {}) { + const reports = []; + for (const profile of FIRST_LOAD_AUDIT_PROFILES) { + for (const caseId of profile.caseIds) { + const testCase = FIRST_LOAD_AUDIT_CASES.find((candidate) => candidate.id === caseId); + if (!testCase) throw new Error(`Unknown first-load audit case: ${caseId}`); + + const before = await capturePreHydrationState(browser, baseUrl, profile, testCase); + const after = await captureHydratedState(browser, baseUrl, profile, testCase); + const report = assertFirstLoadResult(profile, testCase, before, after); + reports.push({ caseId, profileId: profile.id, ...report }); + let warmAfter = null; + if (testCase.warmReload) { + const warmReloadCase = { ...testCase, id: `${testCase.id}-warm-reload` }; + warmAfter = await captureWarmReloadState(browser, baseUrl, profile, testCase); + const warmReport = assertFirstLoadResult(profile, warmReloadCase, before, warmAfter); + reports.push({ caseId: warmReloadCase.id, profileId: profile.id, ...warmReport }); + console.log(`[playwright-smoke] PASS warm reload: ${profile.id}/${testCase.id} CLS=${warmReport.cls.toFixed(4)}`); + } + if (writeArtifacts) { + await writeFirstLoadCaseArtifacts(profile, testCase, before, after, warmAfter); + } + console.log(`[playwright-smoke] PASS first load: ${profile.id}/${caseId} CLS=${report.cls.toFixed(4)}`); + } + } + + if (writeArtifacts) { + await writeFile( + path.join(FIRST_LOAD_ARTIFACT_DIR, "report.json"), + `${JSON.stringify(reports, null, 2)}\n`, + "utf8", + ); + } +} + async function assertHomeNavigation(context, baseUrl, locale) { const page = await context.newPage(); const runtime = createRuntimeObserver(page, `Navigation smoke for ${locale}`, baseUrl); @@ -689,6 +1158,93 @@ async function assertMobileReviewMatrix(browser, baseUrl) { } } +async function assertInputIntentSizingMatrix(browser, baseUrl) { + for (const viewport of INPUT_INTENT_AUDIT_VIEWPORTS) { + const context = await browser.newContext({ + serviceWorkers: "block", + viewport: { width: viewport.width, height: viewport.height }, + isMobile: viewport.mobile, + }); + + try { + for (const audit of INPUT_INTENT_AUDIT_ROUTES) { + const page = await context.newPage(); + const routeLabel = `${audit.route} input intent ${viewport.width}x${viewport.height}`; + const runtime = createRuntimeObserver(page, routeLabel, baseUrl); + + try { + await page.goto(`${baseUrl}${audit.route}`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + await page.locator("[data-input-intent]").first().waitFor({ state: "attached", timeout: 15_000 }); + + const measurements = await page.evaluate(({ mobile }) => { + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0; + }; + const nodes = Array.from(document.querySelectorAll("[data-input-intent]")).filter(isVisible); + const counts = {}; + const issues = []; + + for (const element of nodes) { + const intent = element.getAttribute("data-input-intent"); + if (!intent) continue; + counts[intent] = (counts[intent] || 0) + 1; + + const rect = element.getBoundingClientRect(); + const tag = element.tagName.toLowerCase(); + const field = ["input", "textarea", "select"].includes(tag); + if (intent === "scalar" && field) { + const target = element.closest("label") || element; + const targetRect = target.getBoundingClientRect(); + const minimum = mobile ? 44 : 36; + if (targetRect.height + 0.5 < minimum || targetRect.width + 0.5 < minimum) { + issues.push(`${intent} ${tag}: ${Math.round(targetRect.width)}x${Math.round(targetRect.height)}`); + } + } + if (intent === "shortText" && tag === "input" && rect.height + 0.5 < (mobile ? 44 : 44)) { + issues.push(`${intent} input height: ${Math.round(rect.height)}px`); + } + if (intent === "shortText" && tag === "textarea") { + const style = window.getComputedStyle(element); + const resize = style.resize; + if (rect.height + 0.5 < 144) issues.push(`${intent} textarea height: ${Math.round(rect.height)}px (min-height ${style.minHeight}; ${element.className})`); + if (!resize.includes("vertical") && resize !== "both") issues.push(`${intent} textarea resize: ${resize}`); + } + if (["payload", "generatedOutput"].includes(intent) && ["textarea", "pre"].includes(tag) && rect.height + 0.5 < 256) { + issues.push(`${intent} ${tag} height: ${Math.round(rect.height)}px`); + } + } + + return { counts, issues }; + }, { mobile: viewport.mobile }); + + for (const intent of audit.requiredIntents) { + if (!measurements.counts[intent]) { + throw new Error(`${routeLabel} did not render required ${intent} input intent.`); + } + } + if (measurements.issues.length > 0) { + throw new Error(`${routeLabel} input sizing failed:\n- ${measurements.issues.join("\n- ")}`); + } + + await assertNoHorizontalOverflow(page, routeLabel); + const screenshot = await page.screenshot({ animations: "disabled", fullPage: false }); + if (screenshot.byteLength < 5_000) { + throw new Error(`${routeLabel} sizing screenshot was unexpectedly blank (${screenshot.byteLength} bytes).`); + } + runtime.assertClean(); + } finally { + await page.close(); + } + } + } finally { + await context.close(); + } + } +} + async function assertAxeSeriousCriticalMatrix(browser, baseUrl) { const context = await browser.newContext({ serviceWorkers: "block" }); @@ -1014,6 +1570,93 @@ async function assertBase64PipelineSafeNavigationJourney(context, baseUrl) { await page.close(); } +async function assertDownloadedFile(download, expectedFilename, kind) { + if (download.suggestedFilename() !== expectedFilename) { + throw new Error(`Expected ${expectedFilename}, received ${download.suggestedFilename()}.`); + } + + const failure = await download.failure(); + if (failure) { + throw new Error(`${expectedFilename} download failed: ${failure}`); + } + + const downloadPath = await download.path(); + if (!downloadPath) { + throw new Error(`${expectedFilename} did not produce a downloadable file.`); + } + + const contents = await readFile(downloadPath); + if (contents.length === 0) { + throw new Error(`${expectedFilename} download was empty.`); + } + + if (kind === "png" && !contents.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + throw new Error(`${expectedFilename} did not contain a PNG signature.`); + } + if (kind === "svg" && !contents.toString("utf8").includes(" { + const canvas = document.querySelector("canvas"); + const button = Array.from(document.querySelectorAll("button")) + .find((candidate) => candidate.textContent?.trim() === "PNG"); + return canvas instanceof HTMLCanvasElement && canvas.width > 0 && button && !button.disabled; + }, null, { timeout: 15_000 }); + const pngDownloadPromise = page.waitForEvent("download"); + await pngButton.click(); + const pngDownloadPath = await assertDownloadedFile(await pngDownloadPromise, "qr-code.png", "png"); + + const svgDownloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "SVG", exact: true }).first().click(); + await assertDownloadedFile(await svgDownloadPromise, "qr-code.svg", "svg"); + + const pageCountBeforeDecode = context.pages().length; + await page.getByRole("tab", { name: "Decode image", exact: true }).click(); + const decodeInput = page.locator('input[type="file"][accept*=".png"]').first(); + await decodeInput.setInputFiles({ + name: "qr-code.png", + mimeType: "image/png", + buffer: await readFile(pngDownloadPath), + }); + await page.waitForFunction((expectedPayload) => { + const output = document.querySelector('[data-testid="qr-decoded-output"]')?.textContent?.trim(); + const error = document.querySelector('[data-testid="qr-decode-error"]') + || Array.from(document.querySelectorAll('[role="alert"]')).find((node) => node.textContent?.trim()); + return output === expectedPayload || Boolean(error); + }, "https://byteflow.tools/qr-download-smoke", { timeout: 15_000 }); + const decodeAlert = (await page.getByRole("alert").allTextContents()).filter((message) => message.trim()); + if (decodeAlert.length > 0) { + throw new Error(`QR round-trip decode failed: ${decodeAlert.join(" ")}`); + } + if (context.pages().length !== pageCountBeforeDecode) { + throw new Error("QR decoding opened the decoded URL without explicit user action."); + } + + await decodeInput.setInputFiles({ + name: "blank.png", + mimeType: "image/png", + buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), + }); + await page.getByRole("alert").filter({ hasText: "No QR code was found" }).waitFor({ timeout: 15_000 }); + runtime.assertClean(); + } finally { + await page.close(); + } +} + async function expectTextareaValueAbsent(page, pattern, label) { const found = await page.evaluate((source) => { const regex = new RegExp(source); @@ -1449,7 +2092,7 @@ async function assertPwaShellJourney(browser, baseUrl) { } } -async function runSmoke(baseUrl) { +async function runSmoke(baseUrl, { writeFirstLoadArtifacts = false } = {}) { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ serviceWorkers: "block" }); @@ -1461,6 +2104,9 @@ async function runSmoke(baseUrl) { console.log(`[playwright-smoke] PASS render: ${route}`); } + await assertFirstLoadStabilityMatrix(browser, baseUrl, { writeArtifacts: writeFirstLoadArtifacts }); + console.log("[playwright-smoke] PASS first-load CLS, theme, locale, and hydration screenshot matrix"); + await assertHomeNavigation(context, baseUrl, "en"); console.log("[playwright-smoke] PASS navigation: /en -> /en/json-formatter"); @@ -1482,6 +2128,9 @@ async function runSmoke(baseUrl) { await assertBase64PipelineSafeNavigationJourney(context, baseUrl); console.log("[playwright-smoke] PASS journey: /en/base64-encode-decode -> /en/pipeline-builder safe navigation"); + await assertQrDownloadJourney(context, baseUrl); + console.log("[playwright-smoke] PASS journey: /en/qr-code-generator PNG and SVG downloads"); + await assertPipelineRecipeJourney(context, baseUrl); console.log("[playwright-smoke] PASS journey: /en/pipeline-builder template -> run"); @@ -1503,6 +2152,9 @@ async function runSmoke(baseUrl) { await assertMobileReviewMatrix(browser, baseUrl); console.log("[playwright-smoke] PASS mobile review matrix: no overflow or touch-target regressions"); + await assertInputIntentSizingMatrix(browser, baseUrl); + console.log("[playwright-smoke] PASS input intent sizing matrix: mobile/desktop heights, touch targets, overflow, and screenshots"); + await assertAxeSeriousCriticalMatrix(browser, baseUrl); console.log("[playwright-smoke] PASS accessibility: no serious or critical axe violations on representative pages"); } finally { @@ -1511,6 +2163,15 @@ async function runSmoke(baseUrl) { } } +async function runFirstLoadAudit(baseUrl, { writeArtifacts = false } = {}) { + const browser = await chromium.launch({ headless: true }); + try { + await assertFirstLoadStabilityMatrix(browser, baseUrl, { writeArtifacts }); + } finally { + await browser.close(); + } +} + async function runPwaSmoke(baseUrl) { const browser = await chromium.launch({ headless: true }); try { @@ -1521,8 +2182,26 @@ async function runPwaSmoke(baseUrl) { } } +async function runInputIntentSmoke(baseUrl) { + const browser = await chromium.launch({ headless: true }); + try { + await assertInputIntentSizingMatrix(browser, baseUrl); + console.log("[playwright-smoke] PASS input intent sizing matrix: mobile/desktop heights, touch targets, overflow, and screenshots"); + } finally { + await browser.close(); + } +} + async function main() { - const { baseUrl, port, skipServer, includePwa } = parseArgs(process.argv.slice(2)); + const { + baseUrl, + firstLoadOnly, + includePwa, + inputIntentsOnly, + port, + skipServer, + writeFirstLoadArtifacts, + } = parseArgs(process.argv.slice(2)); let serverHandle = null; try { @@ -1532,11 +2211,19 @@ async function main() { console.log(`[playwright-smoke] Static server ready at ${baseUrl}`); } - await runSmoke(baseUrl); - if (includePwa) { + if (inputIntentsOnly) { + await runInputIntentSmoke(baseUrl); + } else if (firstLoadOnly) { + await runFirstLoadAudit(baseUrl, { writeArtifacts: writeFirstLoadArtifacts }); + } else { + await runSmoke(baseUrl, { writeFirstLoadArtifacts }); + } + if (includePwa && !firstLoadOnly && !inputIntentsOnly) { await runPwaSmoke(baseUrl); } - console.log("[playwright-smoke] PASS: critical routes render and navigate correctly"); + if (!firstLoadOnly && !inputIntentsOnly) { + console.log("[playwright-smoke] PASS: critical routes render and navigate correctly"); + } } catch (error) { console.error("[playwright-smoke] FAILED"); if (serverHandle) { diff --git a/scripts/e2e/run-route-width-matrix.js b/scripts/e2e/run-route-width-matrix.js new file mode 100644 index 00000000..ddefec6b --- /dev/null +++ b/scripts/e2e/run-route-width-matrix.js @@ -0,0 +1,236 @@ +import { createServer } from "node:http" +import { existsSync, mkdirSync, writeFileSync } from "node:fs" +import { createRequire } from "node:module" +import path from "node:path" +import process from "node:process" + +const require = createRequire(import.meta.url) +const { chromium } = require("playwright") +const serveHandler = require("serve-handler") + +const DEFAULT_PORT = 4174 +const OUTPUT_DIR = path.resolve(process.cwd(), "test-results/route-width") +const VIEWPORTS = [ + { name: "desktop-1440", width: 1440, height: 1000 }, + { name: "desktop-1920", width: 1920, height: 1080 }, +] +const ROUTES = [ + { path: "/en/all-tools", intent: "catalog", requiredChromeParts: ["intent"] }, + { path: "/en/qr-code-generator", intent: "tool", requiredChromeParts: ["intent", "tool-meta", "install"], requireRelatedTools: true }, + { path: "/en/json-formatter", intent: "wide-tool", requiredChromeParts: ["intent", "tool-meta", "install", "related-tools"], requireRelatedTools: true }, + { path: "/en/pipeline-builder", intent: "wide-tool", requiredChromeParts: ["intent", "tool-meta", "install", "related-tools"], requireRelatedTools: true }, + { path: "/en/support", intent: "static", requiredChromeParts: [] }, + { path: "/en/install-app", intent: "static", requiredChromeParts: [] }, + { path: "/zh-CN/qr-code-generator", intent: "tool", requiredChromeParts: ["intent", "tool-meta", "install"], requireRelatedTools: true }, +] +const MAX_WIDTH_BY_INTENT = { + tool: 1152, + "wide-tool": 1400, + static: 1024, + catalog: 1152, +} +const EDGE_TOLERANCE_PX = 2 + +function parseArgs(argv) { + const args = { baseUrl: "", port: DEFAULT_PORT, skipServer: false } + for (const arg of argv) { + if (arg === "--skip-server") args.skipServer = true + if (arg.startsWith("--port=")) args.port = Number(arg.slice("--port=".length)) || DEFAULT_PORT + if (arg.startsWith("--base-url=")) args.baseUrl = arg.slice("--base-url=".length).trim() + } + if (!args.baseUrl) args.baseUrl = `http://127.0.0.1:${args.port}` + args.baseUrl = args.baseUrl.replace(/\/+$/, "") + return args +} + +function startStaticServer(port) { + const outDir = path.resolve(process.cwd(), "out") + if (!existsSync(outDir)) { + throw new Error(`[route-width-matrix] Missing ${outDir}. Run npm run build first.`) + } + const server = createServer((request, response) => serveHandler(request, response, { + public: outDir, + cleanUrls: true, + etag: false, + })) + server.listen(port, "127.0.0.1") + return server +} + +async function waitForServer(url) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: "manual" }) + if (response.status < 500) return + } catch { + // The static server may still be binding its port. + } + await new Promise((resolve) => setTimeout(resolve, 300)) + } + throw new Error(`[route-width-matrix] Timed out waiting for ${url}`) +} + +async function closeServer(server) { + if (!server) return + await new Promise((resolve) => server.close(resolve)) +} + +function assertClose(actual, expected, label) { + if (Math.abs(actual - expected) > EDGE_TOLERANCE_PX) { + throw new Error(`${label}: expected ${expected.toFixed(2)} +/- ${EDGE_TOLERANCE_PX}px, received ${actual.toFixed(2)}`) + } +} + +function assertContained(child, parent, label) { + if ( + child.x < parent.x - EDGE_TOLERANCE_PX + || child.x + child.width > parent.x + parent.width + EDGE_TOLERANCE_PX + ) { + throw new Error(`${label} escapes the route shell horizontally: ${JSON.stringify({ child, parent })}`) + } +} + +function assertAligned(child, parent, label) { + assertClose(child.x, parent.x, `${label} left edge`) + assertClose(child.x + child.width, parent.x + parent.width, `${label} right edge`) +} + +async function inspectRoute(page, baseUrl, route, viewport) { + const runtimeErrors = [] + page.on("pageerror", (error) => runtimeErrors.push(error.message)) + page.on("console", (message) => { + if (message.type() === "error" && !message.text().startsWith("Failed to load resource:")) { + runtimeErrors.push(`console.error: ${message.text()}`) + } + }) + + const response = await page.goto(`${baseUrl}${route.path}`, { waitUntil: "domcontentloaded" }) + if (!response?.ok()) throw new Error(`${route.path} failed to load: ${response?.status() ?? "no response"}`) + await page.locator("main").first().waitFor({ state: "visible", timeout: 15_000 }) + await page.locator('[data-route-shell="true"]').waitFor({ state: "visible", timeout: 15_000 }) + await page.locator("[data-page-container]").first().waitFor({ state: "visible", timeout: 15_000 }) + await page.evaluate(() => document.fonts.ready) + + const shell = page.locator('[data-route-shell="true"]') + const body = page.locator("[data-page-container]").first() + const shellIntent = await shell.getAttribute("data-route-container-intent") + const bodyIntent = await body.getAttribute("data-page-container") + if (shellIntent !== route.intent || bodyIntent !== route.intent) { + throw new Error(`${route.path} intent mismatch: expected ${route.intent}, shell=${shellIntent}, body=${bodyIntent}`) + } + + const shellBox = await shell.boundingBox() + const bodyBox = await body.boundingBox() + if (!shellBox || !bodyBox) throw new Error(`${route.path} did not expose measurable shell/body containers`) + assertClose(bodyBox.x, shellBox.x, `${route.path} body left edge`) + assertClose(bodyBox.x + bodyBox.width, shellBox.x + shellBox.width, `${route.path} body right edge`) + if (shellBox.width > MAX_WIDTH_BY_INTENT[route.intent] + EDGE_TOLERANCE_PX) { + throw new Error(`${route.path} ${route.intent} shell exceeds ${MAX_WIDTH_BY_INTENT[route.intent]}px: ${shellBox.width}px`) + } + if (shellBox.x < 16 - EDGE_TOLERANCE_PX || shellBox.x + shellBox.width > viewport.width - 16 + EDGE_TOLERANCE_PX) { + throw new Error(`${route.path} shell violates desktop gutters: ${JSON.stringify(shellBox)}`) + } + + const chromeBounds = await page.locator("[data-route-chrome-part]").evaluateAll((nodes) => nodes.map((node) => { + const rect = node.getBoundingClientRect() + return { + part: node.getAttribute("data-route-chrome-part"), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + } + })) + const chromeByPart = new Map(chromeBounds.map((bounds) => [bounds.part, bounds])) + for (const part of route.requiredChromeParts) { + const bounds = chromeByPart.get(part) + if (!bounds) throw new Error(`${route.path} is missing required ${part} route chrome`) + assertAligned(bounds, shellBox, `${route.path} ${part}`) + } + for (const bounds of chromeBounds) assertContained(bounds, shellBox, `${route.path} ${bounds.part}`) + + const h1 = page.locator("h1").first() + if (await h1.count()) { + const headingBox = await h1.boundingBox() + if (headingBox) assertContained(headingBox, bodyBox, `${route.path} h1`) + } + const relatedTools = page.locator("[data-related-tools-source]").first() + if (route.requireRelatedTools && !(await relatedTools.count())) { + throw new Error(`${route.path} is missing required related tools`) + } + if (await relatedTools.count()) { + const relatedBox = await relatedTools.boundingBox() + if (!relatedBox) throw new Error(`${route.path} related tools are not measurable`) + assertAligned(relatedBox, shellBox, `${route.path} related tools`) + } + + const screenshotDir = path.join(OUTPUT_DIR, viewport.name) + mkdirSync(screenshotDir, { recursive: true }) + const filename = route.path.slice(1).replaceAll("/", "__") || "root" + await page.screenshot({ + path: path.join(screenshotDir, `${filename}.png`), + fullPage: true, + }) + + if (runtimeErrors.length > 0) { + throw new Error(`${route.path} emitted runtime errors:\n- ${runtimeErrors.join("\n- ")}`) + } + + return { + route: route.path, + viewport: viewport.name, + intent: route.intent, + shell: shellBox, + body: bodyBox, + chrome: chromeBounds, + } +} + +async function main() { + const { baseUrl, port, skipServer } = parseArgs(process.argv.slice(2)) + let server = null + let browser = null + const matrix = [] + mkdirSync(OUTPUT_DIR, { recursive: true }) + + try { + if (!skipServer) { + server = startStaticServer(port) + await waitForServer(baseUrl) + } + browser = await chromium.launch({ headless: true }) + + for (const viewport of VIEWPORTS) { + const context = await browser.newContext({ + serviceWorkers: "block", + viewport: { width: viewport.width, height: viewport.height }, + }) + try { + for (const route of ROUTES) { + const page = await context.newPage() + try { + matrix.push(await inspectRoute(page, baseUrl, route, viewport)) + console.log(`[route-width-matrix] PASS ${viewport.name} ${route.path}`) + } finally { + await page.close() + } + } + } finally { + await context.close() + } + } + + writeFileSync(path.join(OUTPUT_DIR, "matrix.json"), `${JSON.stringify(matrix, null, 2)}\n`, "utf8") + console.log(`[route-width-matrix] PASS: wrote ${matrix.length} screenshots and bounding-box records`) + } finally { + if (browser) await browser.close() + await closeServer(server) + } +} + +main().catch((error) => { + console.error("[route-width-matrix] FAILED") + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exit(1) +}) diff --git a/scripts/gates/check-content-template.js b/scripts/gates/check-content-template.js index 865facb2..5ae1cf6d 100644 --- a/scripts/gates/check-content-template.js +++ b/scripts/gates/check-content-template.js @@ -4,7 +4,8 @@ import { loadToolSlugs as loadToolSlugsFromManifests } from "../lib/tool-manifes const DEFAULT_SCAN_DIRS = [".next/server/app", "out"] const SUPPORTED_LOCALES = new Set(["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"]) -const REQUIRED_TEMPLATE_LAYOUT_CLASS_TOKENS = ["mx-auto", "w-full", "max-w-7xl"] +const REQUIRED_TEMPLATE_LAYOUT_CLASS_TOKENS = ["w-full"] +const FORBIDDEN_TEMPLATE_LAYOUT_CLASS_PREFIXES = ["max-w-"] const REQUIRED_TEMPLATE_LAYOUT_ATTRIBUTES = ["data-tool-content-template-width-sync"] const REQUIRED_SECTION_TITLES_BY_LOCALE = { @@ -216,6 +217,11 @@ function main() { failures.push(`${slug}: template layout missing class token "${token}"`) } } + for (const prefix of FORBIDDEN_TEMPLATE_LAYOUT_CLASS_PREFIXES) { + if (classList.some((token) => token.startsWith(prefix))) { + failures.push(`${slug}: template layout must inherit route width instead of using "${prefix}*"`) + } + } for (const attr of REQUIRED_TEMPLATE_LAYOUT_ATTRIBUTES) { if (!templateSection.includes(`${attr}=`)) { diff --git a/scripts/gates/check-legacy-routes.js b/scripts/gates/check-legacy-routes.js index 80597cd3..f2928c37 100644 --- a/scripts/gates/check-legacy-routes.js +++ b/scripts/gates/check-legacy-routes.js @@ -24,8 +24,9 @@ function sitemapContainsSlugUrl(sitemap, slug) { } function main() { - const { routes, taxonomyRedirects } = checkGeneratedLegacyRoutes() + const { routes, taxonomyRedirects, canonicalToolSlugs: generatedCanonicalToolSlugs } = checkGeneratedLegacyRoutes() const canonicalToolSlugs = new Set(loadOrderedToolManifests().map((tool) => tool.slug)) + const redirects = fs.readFileSync(path.join(ROOT, "public/_redirects"), "utf8") const routeGroups = readJson(ROUTE_GROUPS_PATH) const sitemapRouteSlugs = new Set([ ...(routeGroups.hubSlugs || []), @@ -33,6 +34,15 @@ function main() { ]) const problems = [] + for (const slug of generatedCanonicalToolSlugs) { + if (!canonicalToolSlugs.has(slug)) { + problems.push(`${slug}: generated direct-entry redirect is not a canonical tool`) + } + if (!redirects.includes(`/${slug} /en/${slug} 301`)) { + problems.push(`${slug}: missing default-locale direct-entry redirect`) + } + } + for (const route of routes) { if (canonicalToolSlugs.has(route.sourceSlug)) { problems.push(`${route.sourceSlug}: source is still a canonical tool slug`) @@ -83,7 +93,7 @@ function main() { process.exit(1) } - console.log(`[check:legacy-routes] OK: ${routes.length} legacy route(s) and ${Object.keys(taxonomyRedirects).length} legacy taxonomy route(s) have valid targets, redirects, and sitemap exclusions`) + console.log(`[check:legacy-routes] OK: ${generatedCanonicalToolSlugs.length} direct tool route(s), ${routes.length} legacy route(s), and ${Object.keys(taxonomyRedirects).length} legacy taxonomy route(s) have valid targets, redirects, and sitemap exclusions`) } main() diff --git a/scripts/gates/performance-budgets.json b/scripts/gates/performance-budgets.json index 923a8121..f62656b3 100644 --- a/scripts/gates/performance-budgets.json +++ b/scripts/gates/performance-budgets.json @@ -8,7 +8,7 @@ "maxInitialScriptFiles": 12, "maxCssGzipBytes": 35000, "maxCssRawBytes": 220000, - "maxHtmlBytes": 330000 + "maxHtmlBytes": 340000 }, { "route": "/en/all-tools", @@ -38,7 +38,7 @@ "maxInitialScriptFiles": 27, "maxCssGzipBytes": 35000, "maxCssRawBytes": 220000, - "maxHtmlBytes": 320000 + "maxHtmlBytes": 335000 }, { "route": "/en/markdown-preview", @@ -48,7 +48,7 @@ "maxInitialScriptFiles": 23, "maxCssGzipBytes": 35000, "maxCssRawBytes": 220000, - "maxHtmlBytes": 300000 + "maxHtmlBytes": 305000 }, { "route": "/en/image-resizer", @@ -58,7 +58,7 @@ "maxInitialScriptFiles": 27, "maxCssGzipBytes": 35000, "maxCssRawBytes": 220000, - "maxHtmlBytes": 310000 + "maxHtmlBytes": 318000 } ] } diff --git a/scripts/generators/generate-legacy-routes.js b/scripts/generators/generate-legacy-routes.js index b4ed0696..5671ed80 100644 --- a/scripts/generators/generate-legacy-routes.js +++ b/scripts/generators/generate-legacy-routes.js @@ -1,6 +1,7 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" +import { loadOrderedToolManifests } from "../lib/tool-manifest-lib.js" const ROOT = process.cwd() const LEGACY_ROUTES_PATH = path.join(ROOT, "src/core/routing/legacy-routes.json") @@ -8,6 +9,7 @@ const LEGACY_TAXONOMY_REDIRECTS_PATH = path.join(ROOT, "src/core/routing/legacy- const TOOL_ALIASES_PATH = path.join(ROOT, "src/core/registry/tool-aliases.json") const REDIRECTS_PATH = path.join(ROOT, "public/_redirects") const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"] +const DEFAULT_LOCALE = "en" const REDIRECT_STATUSES = new Set([301, 302]) const STATIC_REDIRECT_LINES = [ "/security.txt /.well-known/security.txt 301", @@ -92,9 +94,25 @@ export function loadLegacyTaxonomyRedirects() { return validated } -function buildRedirectLines(routes, taxonomyRedirects = loadLegacyTaxonomyRedirects()) { +function loadCanonicalToolSlugs() { + return loadOrderedToolManifests().map((tool) => tool.slug) +} + +function buildRedirectLines( + routes, + taxonomyRedirects = loadLegacyTaxonomyRedirects(), + canonicalToolSlugs = loadCanonicalToolSlugs(), +) { return [ ...STATIC_REDIRECT_LINES, + ...canonicalToolSlugs.map((slug) => `/${slug} /${DEFAULT_LOCALE}/${slug} 301`), + ...routes.flatMap((route) => { + if (!REDIRECT_STATUSES.has(route.status)) return [] + return [`/${route.sourceSlug} /${DEFAULT_LOCALE}/${route.targetSlug} ${route.status}`] + }), + ...Object.entries(taxonomyRedirects).map(([sourceSlug, targetSlug]) => ( + `/${sourceSlug} /${DEFAULT_LOCALE}/${targetSlug} 301` + )), ...routes.flatMap((route) => { if (!REDIRECT_STATUSES.has(route.status)) return [] return LOCALES.map((locale) => `/${locale}/${route.sourceSlug} /${locale}/${route.targetSlug} ${route.status}`) @@ -105,8 +123,12 @@ function buildRedirectLines(routes, taxonomyRedirects = loadLegacyTaxonomyRedire ] } -export function buildRedirectsSource(routes = loadLegacyRoutes(), taxonomyRedirects = loadLegacyTaxonomyRedirects()) { - const lines = buildRedirectLines(routes, taxonomyRedirects) +export function buildRedirectsSource( + routes = loadLegacyRoutes(), + taxonomyRedirects = loadLegacyTaxonomyRedirects(), + canonicalToolSlugs = loadCanonicalToolSlugs(), +) { + const lines = buildRedirectLines(routes, taxonomyRedirects, canonicalToolSlugs) return `${lines.join("\n")}\n` } @@ -114,8 +136,9 @@ export function buildRedirectsSource(routes = loadLegacyRoutes(), taxonomyRedire export function checkGeneratedLegacyRoutes() { const routes = loadLegacyRoutes() const taxonomyRedirects = loadLegacyTaxonomyRedirects() + const canonicalToolSlugs = loadCanonicalToolSlugs() const expectedAliases = buildToolAliasesSource(routes) - const expectedRedirects = buildRedirectsSource(routes, taxonomyRedirects) + const expectedRedirects = buildRedirectsSource(routes, taxonomyRedirects, canonicalToolSlugs) const problems = [] if (!fs.existsSync(TOOL_ALIASES_PATH) || readText(TOOL_ALIASES_PATH) !== expectedAliases) { @@ -131,14 +154,15 @@ export function checkGeneratedLegacyRoutes() { process.exit(1) } - return { routes, taxonomyRedirects } + return { routes, taxonomyRedirects, canonicalToolSlugs } } function writeGeneratedFiles() { const routes = loadLegacyRoutes() const taxonomyRedirects = loadLegacyTaxonomyRedirects() + const canonicalToolSlugs = loadCanonicalToolSlugs() fs.writeFileSync(TOOL_ALIASES_PATH, buildToolAliasesSource(routes)) - fs.writeFileSync(REDIRECTS_PATH, buildRedirectsSource(routes, taxonomyRedirects)) + fs.writeFileSync(REDIRECTS_PATH, buildRedirectsSource(routes, taxonomyRedirects, canonicalToolSlugs)) console.log(`[generate:legacy-routes] wrote ${path.relative(ROOT, TOOL_ALIASES_PATH)}`) console.log(`[generate:legacy-routes] wrote ${path.relative(ROOT, REDIRECTS_PATH)}`) } diff --git a/scripts/generators/generate-route-width-inventory.js b/scripts/generators/generate-route-width-inventory.js new file mode 100644 index 00000000..77df5174 --- /dev/null +++ b/scripts/generators/generate-route-width-inventory.js @@ -0,0 +1,427 @@ +#!/usr/bin/env node + +import fs from "node:fs" +import { createRequire } from "node:module" +import path from "node:path" +import process from "node:process" + +const require = createRequire(import.meta.url) +const ts = require("typescript") + +const ROOT_DIR = process.cwd() +const INVENTORY_PATH = path.join(ROOT_DIR, "src/generated/route-width-inventory.json") +const CONTRACT_PATH = path.join(ROOT_DIR, "src/components/layout/route-container-contract.ts") +const TOOL_ROOT = path.join(ROOT_DIR, "src/features/tools") +const SCAFFOLD_PATH = path.join(ROOT_DIR, "scripts/scaffolding/create-tool.js") +const PAGE_CONTAINER_NAMES = new Set([ + "CatalogPageContainer", + "StaticPageContainer", + "ToolPageContainer", + "WideToolPageContainer", +]) +const SCAN_ROOTS = [ + ["app", "src/app"], + ["layout", "src/components/layout"], + ["tools", "src/features/tools"], + ["route-surfaces", "src/core/seo/components"], + ["install", "src/features/install-app"], + ["scaffold", "scripts/scaffolding/create-tool.js"], +] +const WIDTH_TOKEN_PATTERN = /(? listSourceFiles(path.join(targetPath, entry.name))) + .filter((filePath) => /\.(?:js|ts|tsx)$/.test(filePath)) +} + +function unwrapExpression(node) { + let current = node + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isSatisfiesExpression(current) + || ts.isNonNullExpression(current) + || ts.isAwaitExpression(current) + ) { + current = current.expression + } + return current +} + +function propertyNameText(name, sourceFile) { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text + return name.getText(sourceFile) +} + +function findVariable(sourceFile, variableName) { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.name.text === variableName) { + return declaration.initializer ? unwrapExpression(declaration.initializer) : null + } + } + } + return null +} + +function readContract() { + const source = fs.readFileSync(CONTRACT_PATH, "utf8") + const sourceFile = ts.createSourceFile(CONTRACT_PATH, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const classMapNode = findVariable(sourceFile, "ROUTE_CONTAINER_CLASS_NAMES") + const viewportNode = findVariable(sourceFile, "ROUTE_VIEWPORT_CLASS_NAME") + const representativeNode = findVariable(sourceFile, "REPRESENTATIVE_ROUTE_CONTAINER_INTENTS") + const wideSetNode = findVariable(sourceFile, "WIDE_TOOL_SLUGS") + + if (!classMapNode || !ts.isObjectLiteralExpression(classMapNode)) { + throw new Error("[route-width-inventory] ROUTE_CONTAINER_CLASS_NAMES must be an object literal") + } + if (!viewportNode || !ts.isStringLiteral(viewportNode)) { + throw new Error("[route-width-inventory] ROUTE_VIEWPORT_CLASS_NAME must be a string literal") + } + if (!representativeNode || !ts.isObjectLiteralExpression(representativeNode)) { + throw new Error("[route-width-inventory] REPRESENTATIVE_ROUTE_CONTAINER_INTENTS must be an object literal") + } + if (!wideSetNode || !ts.isNewExpression(wideSetNode)) { + throw new Error("[route-width-inventory] WIDE_TOOL_SLUGS must be a Set literal") + } + + const intents = Object.fromEntries(classMapNode.properties.map((property) => { + if (!ts.isPropertyAssignment(property) || !ts.isStringLiteral(unwrapExpression(property.initializer))) { + throw new Error("[route-width-inventory] Container class entries must be string literals") + } + return [propertyNameText(property.name, sourceFile), unwrapExpression(property.initializer).text] + })) + const representativeRoutes = Object.fromEntries(representativeNode.properties.map((property) => { + if (!ts.isPropertyAssignment(property) || !ts.isStringLiteral(unwrapExpression(property.initializer))) { + throw new Error("[route-width-inventory] Representative route intents must be string literals") + } + return [propertyNameText(property.name, sourceFile), unwrapExpression(property.initializer).text] + })) + const wideArray = wideSetNode.arguments?.[0] + if (!wideArray || !ts.isArrayLiteralExpression(wideArray)) { + throw new Error("[route-width-inventory] WIDE_TOOL_SLUGS must be created from an array literal") + } + const wideToolSlugs = wideArray.elements.map((element) => { + const value = unwrapExpression(element) + if (!ts.isStringLiteral(value)) { + throw new Error("[route-width-inventory] Wide tool slugs must be string literals") + } + return value.text + }).sort() + + return { + viewport: viewportNode.text, + intents, + representativeRoutes, + wideToolSlugs, + } +} + +function collectStaticClassFragments(node) { + const expression = unwrapExpression(node) + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) { + return [expression.text] + } + if (ts.isTemplateExpression(expression)) { + return [ + expression.head.text, + ...expression.templateSpans.flatMap((span) => [ + ...collectStaticClassFragments(span.expression), + span.literal.text, + ]), + ] + } + + const fragments = [] + ts.forEachChild(expression, (child) => fragments.push(...collectStaticClassFragments(child))) + return fragments +} + +function getClassName(openingElement) { + for (const attribute of openingElement.attributes.properties) { + if (!ts.isJsxAttribute(attribute) || attribute.name.text !== "className" || !attribute.initializer) continue + if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text + if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression) { + return collectStaticClassFragments(attribute.initializer.expression).join(" ") + } + } + return "" +} + +function getLiteralJsxAttribute(openingElement, attributeName) { + for (const attribute of openingElement.attributes.properties) { + if (!ts.isJsxAttribute(attribute) || attribute.name.text !== attributeName || !attribute.initializer) continue + return ts.isStringLiteral(attribute.initializer) ? attribute.initializer.text : "" + } + return "" +} + +function collectCenteredMaxWidthWrappers(filePath) { + const source = fs.readFileSync(filePath, "utf8") + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + const wrappers = [] + + const visit = (node) => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const openingElement = ts.isJsxElement(node) ? node.openingElement : node + const className = getClassName(openingElement) + const maxWidthTokens = [...className.matchAll(/(?:^|\s)(max-w-(?:\[[^\]]+\]|[^\s]+))/g)] + .map((match) => match[1]) + if (/(?:^|\s)mx-auto(?:\s|$)/.test(className) && maxWidthTokens.length > 0) { + wrappers.push({ + file: toPosix(filePath), + element: openingElement.tagName.getText(sourceFile), + line: sourceFile.getLineAndCharacterOfPosition(openingElement.getStart(sourceFile)).line + 1, + reason: getLiteralJsxAttribute(openingElement, "data-route-width-exception"), + maxWidthTokens, + }) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return wrappers +} + +function collectPageContainerImports(filePath) { + const source = fs.readFileSync(filePath, "utf8") + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + const imports = new Map() + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== "@/components/layout/page-container") continue + const bindings = statement.importClause?.namedBindings + if (!bindings || !ts.isNamedImports(bindings)) continue + for (const element of bindings.elements) { + imports.set(element.name.text, element.propertyName?.text ?? element.name.text) + } + } + return imports +} + +function collectJsxRoots(expression, sourceFile) { + const node = unwrapExpression(expression) + if (ts.isConditionalExpression(node)) { + return [ + ...collectJsxRoots(node.whenTrue, sourceFile), + ...collectJsxRoots(node.whenFalse, sourceFile), + ] + } + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const openingElement = ts.isJsxElement(node) ? node.openingElement : node + return [{ + element: openingElement.tagName.getText(sourceFile), + className: getClassName(openingElement), + line: sourceFile.getLineAndCharacterOfPosition(openingElement.getStart(sourceFile)).line + 1, + }] + } + if (ts.isJsxFragment(node)) { + return [{ element: "Fragment", className: "", line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 }] + } + return [] +} + +function isExportedPageFunction(node, filePath) { + if (!ts.isFunctionDeclaration(node)) return false + const modifiers = node.modifiers ?? [] + const isDefault = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + const isExported = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + const isNamedPage = Boolean(node.name?.text.endsWith("Page")) + const isRouteSurface = new Set(["error.tsx", "loading.tsx", "not-found.tsx", "page.tsx"]) + .has(path.basename(filePath)) + return (isDefault && (isNamedPage || isRouteSurface)) || (isExported && isNamedPage) +} + +function collectPageRoots(filePath) { + const source = fs.readFileSync(filePath, "utf8") + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + const roots = [] + + for (const statement of sourceFile.statements) { + if (!isExportedPageFunction(statement, filePath)) continue + const component = statement.name?.text ?? "default" + const visit = (node) => { + if (node !== statement && ts.isFunctionLike(node)) return + if (ts.isReturnStatement(node) && node.expression) { + for (const root of collectJsxRoots(node.expression, sourceFile)) { + roots.push({ + file: toPosix(filePath), + component, + ...root, + hardcodedMaxWidth: [...root.className.matchAll(/(?:^|\s)(max-w-(?:\[[^\]]+\]|[^\s]+))/g)] + .map((match) => match[1]), + }) + } + } + ts.forEachChild(node, visit) + } + visit(statement) + } + + return roots +} + +function buildWidthTokenInventory() { + const areas = {} + const scannedFiles = new Set() + + for (const [area, targetPath] of SCAN_ROOTS) { + const counts = new Map() + for (const filePath of listSourceFiles(targetPath)) { + scannedFiles.add(filePath) + const source = fs.readFileSync(filePath, "utf8") + for (const match of source.matchAll(WIDTH_TOKEN_PATTERN)) { + const token = match[1] + counts.set(token, (counts.get(token) ?? 0) + 1) + } + } + areas[area] = Object.fromEntries([...counts.entries()].sort(([left], [right]) => left.localeCompare(right))) + } + + return { areas, scannedFiles: [...scannedFiles].sort() } +} + +function buildInventory() { + const contract = readContract() + const { areas, scannedFiles } = buildWidthTokenInventory() + const pageRoots = scannedFiles.flatMap(collectPageRoots) + .sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line) + const centeredMaxWidthWrappers = scannedFiles.flatMap(collectCenteredMaxWidthWrappers) + .sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line) + const violations = [] + + for (const root of pageRoots) { + if (root.hardcodedMaxWidth.length > 0) { + violations.push(`${root.file}:${root.line} ${root.component} hardcodes ${root.hardcodedMaxWidth.join(", ")} at its page root`) + } + } + + const usedCenteredMaxWidthAllowlistEntries = new Set() + for (const wrapper of centeredMaxWidthWrappers) { + for (const token of wrapper.maxWidthTokens) { + const allowlistEntry = `${wrapper.file}::${wrapper.reason}::${token}` + if (!wrapper.reason || !CENTERED_MAX_WIDTH_ALLOWLIST.has(allowlistEntry)) { + violations.push(`${wrapper.file}:${wrapper.line} centered ${token} wrapper is not explicitly allowlisted`) + continue + } + usedCenteredMaxWidthAllowlistEntries.add(allowlistEntry) + } + } + for (const allowlistEntry of CENTERED_MAX_WIDTH_ALLOWLIST) { + if (!usedCenteredMaxWidthAllowlistEntries.has(allowlistEntry)) { + violations.push(`unused centered max-width allowlist entry: ${allowlistEntry}`) + } + } + + const toolSurfaces = [] + const wideToolSlugs = new Set(contract.wideToolSlugs) + const toolDirs = fs.readdirSync(TOOL_ROOT, { withFileTypes: true }).filter((entry) => entry.isDirectory()) + for (const entry of toolDirs) { + const filePath = path.join(TOOL_ROOT, entry.name, "page.tsx") + if (!fs.existsSync(filePath)) continue + const roots = pageRoots.filter((root) => root.file === toPosix(filePath)) + if (roots.length === 0) { + violations.push(`${toPosix(filePath)} does not export a detectable *Page component`) + continue + } + + const expectedElement = wideToolSlugs.has(entry.name) ? "WideToolPageContainer" : "ToolPageContainer" + const containerImports = collectPageContainerImports(filePath) + if (containerImports.get(expectedElement) !== expectedElement) { + violations.push(`${toPosix(filePath)} must import ${expectedElement} from @/components/layout/page-container without aliasing`) + } + for (const root of roots) { + if (!PAGE_CONTAINER_NAMES.has(root.element)) { + violations.push(`${root.file}:${root.line} ${root.component} must use a page container primitive, found ${root.element}`) + } else if (root.element !== expectedElement) { + violations.push(`${root.file}:${root.line} ${entry.name} expects ${expectedElement}, found ${root.element}`) + } + } + toolSurfaces.push({ + slug: entry.name, + intent: wideToolSlugs.has(entry.name) ? "wide-tool" : "tool", + roots: [...new Set(roots.map((root) => root.element))].sort(), + }) + } + + const scaffoldSource = fs.readFileSync(SCAFFOLD_PATH, "utf8") + const featureTemplateStart = scaffoldSource.indexOf("function createFeaturePageTemplate") + const featureTemplateEnd = scaffoldSource.indexOf("function createTypesTemplate", featureTemplateStart) + const featureTemplate = scaffoldSource.slice(featureTemplateStart, featureTemplateEnd) + if (!featureTemplate.includes('import { ToolPageContainer } from "@/components/layout/page-container"')) { + violations.push("scripts/scaffolding/create-tool.js must import ToolPageContainer in generated feature pages") + } + if (!featureTemplate.includes(" 0) { + throw new Error(`[route-width-inventory] Contract violations:\n- ${violations.join("\n- ")}`) + } + + return { + schemaVersion: 1, + contracts: contract, + summary: { + scannedFiles: scannedFiles.length, + pageRoots: pageRoots.length, + toolSurfaces: toolSurfaces.length, + standardTools: toolSurfaces.filter((tool) => tool.intent === "tool").length, + wideTools: toolSurfaces.filter((tool) => tool.intent === "wide-tool").length, + }, + toolSurfaces: toolSurfaces.sort((left, right) => left.slug.localeCompare(right.slug)), + centeredMaxWidthExceptions: centeredMaxWidthWrappers, + pageRoots: pageRoots.map(({ file, component, element, line }) => ({ file, component, element, line })), + widthTokensByArea: areas, + } +} + +function main() { + const expected = `${JSON.stringify(buildInventory(), null, 2)}\n` + if (process.argv.includes("--check")) { + const current = fs.existsSync(INVENTORY_PATH) ? fs.readFileSync(INVENTORY_PATH, "utf8") : "" + if (current !== expected) { + throw new Error("[route-width-inventory] Generated inventory is stale. Run npm run generate:route-width-inventory.") + } + console.log("[route-width-inventory] PASS: generated inventory and page-root contracts are current") + return + } + + fs.writeFileSync(INVENTORY_PATH, expected, "utf8") + console.log(`[route-width-inventory] Wrote ${path.relative(ROOT_DIR, INVENTORY_PATH)}`) +} + +try { + main() +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/scripts/generators/generate-runtime-scripts.js b/scripts/generators/generate-runtime-scripts.js index 5bda5fa4..75106b84 100644 --- a/scripts/generators/generate-runtime-scripts.js +++ b/scripts/generators/generate-runtime-scripts.js @@ -140,14 +140,25 @@ function buildThemeManifestBootstrapScript(config) { var activeLang = locales.indexOf(seg) >= 0 ? seg : ${defaultLocale}; document.documentElement.setAttribute("lang", activeLang); - var t = localStorage.getItem("theme"); + var t = null; + try { + t = localStorage.getItem("theme"); + } catch {} + if (t !== "light" && t !== "dark" && t !== "system") t = null; if (!t) { - var m = document.cookie.match(/(?:^|;\\s*)theme=([^;]*)/); - t = m ? m[1] : null; + try { + var m = document.cookie.match(/(?:^|;\\s*)theme=([^;]*)/); + t = m ? m[1] : null; + } catch {} } + if (t !== "light" && t !== "dark" && t !== "system") t = null; if (!t) t = "dark"; if (t === "system") { - t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + try { + t = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } catch { + t = "dark"; + } } document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.add(t); diff --git a/scripts/scaffolding/create-tool.js b/scripts/scaffolding/create-tool.js index 8b3ffaf9..cf754c18 100755 --- a/scripts/scaffolding/create-tool.js +++ b/scripts/scaffolding/create-tool.js @@ -253,10 +253,11 @@ function createFeaturePageTemplate(toolKey) { return `"use client" import * as React from "react" -import { Copy, Play } from "lucide-react" +import { Copy, Play, TestTube2, Trash2 } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" +import { ToolPageContainer } from "@/components/layout/page-container" import { useLang } from "@/core/i18n/lang-provider" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { requireTranslationValue } from "@/core/i18n/i18n" @@ -270,18 +271,30 @@ export function ${componentName}Page() { const description = requireTranslationValue(toolT?.description, "tools.${toolKey}.description") const runLabel = requireTranslationValue(t.common.run, "common.run") const copyLabel = requireTranslationValue(t.common.copy, "common.copy") + const sampleLabel = requireTranslationValue(t.common.sample, "common.sample") + const clearLabel = requireTranslationValue(t.common.clear, "common.clear") const inputLabel = requireTranslationValue(t.common.input, "common.input") const outputLabel = requireTranslationValue(t.common.output, "common.output") const copyFailedLabel = requireTranslationValue(t.common.copy_failed, "common.copy_failed") const copiedLabel = requireTranslationValue(t.common.copied, "common.copied") const copiedDescLabel = requireTranslationValue(t.common.copied_desc, "common.copied_desc") - const [input, setInput] = React.useState(SAMPLE_INPUT) + const [input, setInput] = React.useState("") const [output, setOutput] = React.useState("") const run = () => { setOutput(runTool(input)) } + const handleSample = () => { + setInput(SAMPLE_INPUT) + setOutput("") + } + + const handleClear = () => { + setInput("") + setOutput("") + } + const handleCopy = async () => { if (!output) return const result = await safeClipboardWrite(output) @@ -295,13 +308,17 @@ export function ${componentName}Page() { } return ( -
+

{title}

{description}

+ +
-
+