diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 00000000..e4bfa461 --- /dev/null +++ b/public/_redirects @@ -0,0 +1,7 @@ +/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 +/ja/cron-expression-generator /ja/crontab-generator 301 +/ko/cron-expression-generator /ko/crontab-generator 301 +/de/cron-expression-generator /de/crontab-generator 301 +/fr/cron-expression-generator /fr/crontab-generator 301 diff --git a/scripts/gates/check-faq-schema.js b/scripts/gates/check-faq-schema.js index 52547194..7774fb20 100644 --- a/scripts/gates/check-faq-schema.js +++ b/scripts/gates/check-faq-schema.js @@ -4,6 +4,7 @@ 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 ALLOWED_FAQ_SCHEMA_SLUGS = new Set(["json-formatter", "base64-encode-decode", "jwt-decoder"]) function parseLocale() { const localeArgIndex = process.argv.indexOf("--locale") @@ -71,6 +72,13 @@ function main() { } const html = fs.readFileSync(htmlPath, "utf8") + if (!ALLOWED_FAQ_SCHEMA_SLUGS.has(slug)) { + if (html.includes("data-faq-schema=\"tool\"") || html.includes("\"@type\":\"FAQPage\"")) { + failures.push(`${slug}: unexpected FAQPage schema on non-allowlisted tool`) + } + continue + } + if (!html.includes("data-faq-schema=\"tool\"")) { failures.push(`${slug}: FAQ schema marker not found`) continue @@ -95,7 +103,7 @@ function main() { process.exit(1) } - console.log(`[check:faq-schema] OK (${locale}): ${targetSlugs.length}/${targetSlugs.length} tool pages include valid FAQPage schema blocks`) + console.log(`[check:faq-schema] OK (${locale}): ${ALLOWED_FAQ_SCHEMA_SLUGS.size} allowlisted tool pages include valid FAQPage schema blocks`) } main() diff --git a/scripts/lib/sitemap-lastmod-lib.js b/scripts/lib/sitemap-lastmod-lib.js index b61fc7bd..02382d1c 100644 --- a/scripts/lib/sitemap-lastmod-lib.js +++ b/scripts/lib/sitemap-lastmod-lib.js @@ -4,19 +4,11 @@ import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { listManifestFiles, loadToolSlugs } from "./tool-manifest-lib.js"; +import { loadOrderedToolManifests, loadToolSlugs } from "./tool-manifest-lib.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.join(__dirname, "../.."); const ROUTE_GROUPS_PATH = path.join(ROOT_DIR, "src/lib/sitemap-route-groups.json"); -const TOOL_REGISTRY_SHARED_FILES = [ - "src/core/registry/categories.ts", - "src/core/registry/manifests.ts", - "src/core/registry/registry.ts", - "src/core/registry/related-tools.ts", - "src/core/registry/tool-order.json", - "src/core/registry/types.ts", -]; const MANIFEST_RELATIVE_PATH = "src/lib/sitemap-lastmod.json"; const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"]; @@ -30,7 +22,6 @@ const ROUTE_FILE_NAMES = [ "layout.jsx", "layout.js", ]; -const GLOBAL_ROUTE_FILES = ["src/app/layout.tsx", "src/app/[lang]/layout.tsx"]; const DEFAULT_FALLBACK_ISO = "2026-02-25T00:00:00.000Z"; function runGit(args) { @@ -145,36 +136,47 @@ function readRouteGroups() { return JSON.parse(fs.readFileSync(ROUTE_GROUPS_PATH, "utf8")); } -function toProjectRelativePath(absolutePath) { - return path.relative(ROOT_DIR, absolutePath).replace(/\\/g, "/"); +function buildToolManifestPathBySlug() { + const manifestPathBySlug = new Map(); + for (const manifest of loadOrderedToolManifests()) { + if (manifest.sourceFile) { + manifestPathBySlug.set(manifest.slug, manifest.sourceFile); + } + } + return manifestPathBySlug; } -function getToolMetaTrackedFiles() { - return [ - ...TOOL_REGISTRY_SHARED_FILES, - ...listManifestFiles().map((manifestPath) => toProjectRelativePath(manifestPath)), - ] - .filter((relativePath) => isTrackedFile(relativePath)); -} +const TOOL_MANIFEST_PATH_BY_SLUG = buildToolManifestPathBySlug(); function readToolSlugs() { return loadToolSlugs(); } function getRouteFilesForSlug(slug) { + if (!slug) { + return ["src/app/[lang]/page.tsx"].filter((filePath) => isTrackedFile(filePath)); + } + const base = slug ? `src/app/[lang]/${slug}` : "src/app/[lang]"; return ROUTE_FILE_NAMES .map((name) => `${base}/${name}`) .filter((filePath) => isTrackedFile(filePath)); } -function resolveRouteLastmod({ locale, slug, includeToolMeta, fallbackIso }) { - const paths = dedupePaths([ - ...GLOBAL_ROUTE_FILES, +function getToolMetaTrackedFiles(slug) { + const manifestPath = TOOL_MANIFEST_PATH_BY_SLUG.get(slug); + return manifestPath && isTrackedFile(manifestPath) ? [manifestPath] : []; +} + +export function buildLastmodInputPaths({ slug, includeToolMeta }) { + return dedupePaths([ ...getRouteFilesForSlug(slug), - `src/core/i18n/translations/${locale}.json`, - ...(includeToolMeta ? getToolMetaTrackedFiles() : []), + ...(includeToolMeta && slug ? getToolMetaTrackedFiles(slug) : []), ].filter(Boolean)); +} + +function resolveRouteLastmod({ slug, includeToolMeta, fallbackIso }) { + const paths = buildLastmodInputPaths({ slug, includeToolMeta }); const routeIsos = paths.map((filePath) => getEffectiveTrackedIso(filePath)); return selectLatestIso(routeIsos, fallbackIso); @@ -182,7 +184,7 @@ function resolveRouteLastmod({ locale, slug, includeToolMeta, fallbackIso }) { function buildLocaleMap({ slug, includeToolMeta, fallbackIso }) { return Object.fromEntries( - LOCALES.map((locale) => [locale, resolveRouteLastmod({ locale, slug, includeToolMeta, fallbackIso })]) + LOCALES.map((locale) => [locale, resolveRouteLastmod({ slug, includeToolMeta, fallbackIso })]) ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8a3506f2..91372529 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -63,7 +63,13 @@ export default function RootPage() { if (supported.indexOf(lang) < 0) lang = 'en'; } - var suffix = (window.location.search || '') + (window.location.hash || ''); + var search = window.location.search || ''; + var hash = window.location.hash || ''; + if (search.indexOf('handoff=') >= 0 || search.indexOf('handoff_ref=') >= 0) { + hash = '#' + search.slice(1); + search = ''; + } + var suffix = search + hash; var target = '/' + lang + suffix; window.location.replace(target); })(); diff --git a/src/app/robots.ts b/src/app/robots.ts index 3a42c66d..46393e9d 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -6,6 +6,10 @@ export default function robots(): MetadataRoute.Robots { rules: { userAgent: '*', allow: '/', + disallow: [ + '*?handoff=', + '*?handoff_ref=', + ], }, sitemap: 'https://byteflow.tools/sitemap.xml', } diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 4c3e9d32..0b63acc6 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -63,10 +63,10 @@ function getToolLastmod(locale: Locale, slug: string, homeFallback: Date): Date ) } -function buildAlternates(pathBuilder: (locale: Locale) => string) { +function buildAlternates(pathBuilder: (locale: Locale) => string, xDefaultUrl = pathBuilder("en")) { return Object.fromEntries([ ...LOCALES.map((locale) => [locale, pathBuilder(locale)]), - ["x-default", pathBuilder("en")], + ["x-default", xDefaultUrl], ]) } @@ -83,7 +83,7 @@ function buildCoreEntries(): MetadataRoute.Sitemap { alternates: { languages: Object.fromEntries([ ...LOCALES.map((locale) => [locale, `${BASE_URL}/${locale}`]), - ["x-default", `${BASE_URL}/`], + ["x-default", BASE_URL], ]), }, }) @@ -96,7 +96,7 @@ function buildCoreEntries(): MetadataRoute.Sitemap { changeFrequency: "weekly", priority: 1, alternates: { - languages: buildAlternates((l) => `${BASE_URL}/${l}`), + languages: buildAlternates((l) => `${BASE_URL}/${l}`, BASE_URL), }, }) diff --git a/src/core/routing/tool-handoff.ts b/src/core/routing/tool-handoff.ts index 5cd8cf11..f99aff64 100644 --- a/src/core/routing/tool-handoff.ts +++ b/src/core/routing/tool-handoff.ts @@ -55,7 +55,7 @@ export function buildShareableToolHandoffHref(lang: string, slug: string, payloa if (!text) return basePath const encoded = toBase64Url(text) - return `${basePath}?${HANDOFF_PARAM}=${encodeURIComponent(encoded)}` + return `${basePath}#${HANDOFF_PARAM}=${encodeURIComponent(encoded)}` } export const buildToolHandoffHref = buildShareableToolHandoffHref @@ -123,16 +123,23 @@ export function buildToolHandoffLink(lang: string, slug: string, payload: string const handoffRef = createHandoffRef() return { - href: `${basePath}?${HANDOFF_REF_PARAM}=${encodeURIComponent(handoffRef)}`, + href: `${basePath}#${HANDOFF_REF_PARAM}=${encodeURIComponent(handoffRef)}`, prime: () => storeHandoffPayload(handoffRef, text), } } -export function getToolHandoffFromSearchParams(searchParams: URLSearchParams): string | null { - const raw = searchParams.get(HANDOFF_PARAM) +function normalizeFragmentParams(fragment?: string): URLSearchParams { + if (!fragment) return new URLSearchParams() + const normalized = fragment.startsWith("#") ? fragment.slice(1) : fragment + return new URLSearchParams(normalized) +} + +export function getToolHandoffFromSearchParams(searchParams: URLSearchParams, fragment?: string): string | null { + const fragmentParams = normalizeFragmentParams(fragment) + const raw = fragmentParams.get(HANDOFF_PARAM) ?? searchParams.get(HANDOFF_PARAM) if (raw) return fromBase64Url(raw) - const handoffRef = searchParams.get(HANDOFF_REF_PARAM) + const handoffRef = fragmentParams.get(HANDOFF_REF_PARAM) ?? searchParams.get(HANDOFF_REF_PARAM) if (!handoffRef) return null return readHandoffPayload(handoffRef) } diff --git a/src/core/security/inline-script-policy.ts b/src/core/security/inline-script-policy.ts index de92d5e2..e349c830 100644 --- a/src/core/security/inline-script-policy.ts +++ b/src/core/security/inline-script-policy.ts @@ -24,13 +24,12 @@ export const INLINE_SCRIPT_POLICY: readonly InlineScriptPolicyEntry[] = [ { id: "legacy-tool-redirect", file: "src/core/seo/components/legacy-tool-redirect-page.tsx", - purpose: "Client-side redirect for statically exported legacy tool aliases.", - requiresUnsafeInline: true, - migrationPath: "Replace with static redirect artifacts or a hashed redirect bootstrap per alias.", + purpose: "Fallback content for statically exported legacy tool aliases after deployment-level redirects.", + requiresUnsafeInline: false, + migrationPath: "Keep alias redirects in public/_redirects and remove this fallback when old exports can be dropped.", }, ] export function inlineScriptPolicyRequiresUnsafeInline(): boolean { return INLINE_SCRIPT_POLICY.some((entry) => entry.requiresUnsafeInline) } - diff --git a/src/core/seo/components/legacy-tool-redirect-page.tsx b/src/core/seo/components/legacy-tool-redirect-page.tsx index fcaa97a1..d7bde6ec 100644 --- a/src/core/seo/components/legacy-tool-redirect-page.tsx +++ b/src/core/seo/components/legacy-tool-redirect-page.tsx @@ -6,33 +6,17 @@ type LegacyToolRedirectPageProps = { } export function LegacyToolRedirectPage({ href, title, body, cta }: LegacyToolRedirectPageProps) { - const escapedHref = JSON.stringify(href) - return ( - <> -