Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion scripts/gates/check-faq-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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()
52 changes: 27 additions & 25 deletions scripts/lib/sitemap-lastmod-lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand All @@ -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) {
Expand Down Expand Up @@ -145,44 +136,55 @@ 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);
}

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 })])
);
}

Expand Down
8 changes: 7 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})();
Expand Down
4 changes: 4 additions & 0 deletions src/app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export default function robots(): MetadataRoute.Robots {
rules: {
userAgent: '*',
allow: '/',
disallow: [
'*?handoff=',
'*?handoff_ref=',
],
},
sitemap: 'https://byteflow.tools/sitemap.xml',
}
Expand Down
8 changes: 4 additions & 4 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
])
}

Expand All @@ -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],
]),
},
})
Expand All @@ -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),
},
})

Expand Down
17 changes: 12 additions & 5 deletions src/core/routing/tool-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
7 changes: 3 additions & 4 deletions src/core/security/inline-script-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

38 changes: 11 additions & 27 deletions src/core/seo/components/legacy-tool-redirect-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,17 @@ type LegacyToolRedirectPageProps = {
}

export function LegacyToolRedirectPage({ href, title, body, cta }: LegacyToolRedirectPageProps) {
const escapedHref = JSON.stringify(href)

return (
<>
<script
dangerouslySetInnerHTML={{
__html: `
(function () {
var target = ${escapedHref};
if (window.location.pathname + window.location.search + window.location.hash !== target) {
window.location.replace(target);
}
})();
`,
}}
/>
<main className="mx-auto max-w-xl px-6 py-16 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-3 text-sm text-muted-foreground">
{body}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<a className="underline underline-offset-4" href={href}>
{cta}
</a>
</p>
</main>
</>
<main className="mx-auto max-w-xl px-6 py-16 text-center">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-3 text-sm text-muted-foreground">
{body}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<a className="underline underline-offset-4" href={href}>
{cta}
</a>
</p>
</main>
)
}
12 changes: 9 additions & 3 deletions src/core/seo/components/tool-content-template-modules/core.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import type {
} from "./types"

export const SEO_CONTENT_TEMPLATE_LOCALES = new Set<Locale>(["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"])
export const FAQ_SCHEMA_TOOL_SLUGS = new Set(["json-formatter", "base64-encode-decode", "jwt-decoder"])

export function shouldEmitFaqSchema(toolSlug: string) {
return FAQ_SCHEMA_TOOL_SLUGS.has(toolSlug)
}

function buildFallbackContentTemplate(
toolSlug: string,
Expand Down Expand Up @@ -69,6 +74,7 @@ export function buildToolTemplateModel({
const intentProfile = localizedEntry ? null : pack.intentContent?.[intent]

return {
toolSlug,
title,
content,
copy: getTemplateCopy(lang),
Expand All @@ -85,7 +91,7 @@ export function ToolContentTemplateSection({
model: ToolTemplateRenderModel
source?: "client" | "server"
}) {
const faqSchema = {
const faqSchema = shouldEmitFaqSchema(model.toolSlug) ? {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: model.content.faqs.map((item) => ({
Expand All @@ -96,11 +102,11 @@ export function ToolContentTemplateSection({
text: item.a,
},
})),
}
} : null

return (
<>
<JsonLdScript data-faq-schema="tool" jsonLd={faqSchema} />
{faqSchema ? <JsonLdScript data-faq-schema="tool" jsonLd={faqSchema} /> : null}
<ToolContentTemplateSurface source={source}>
<div className="mx-auto max-w-4xl space-y-8">
<header className="space-y-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export type TemplateCopy = {
}

export type ToolTemplateRenderModel = {
toolSlug: string
title: string
content: ToolContentTemplateData
copy: TemplateCopy
Expand Down
2 changes: 1 addition & 1 deletion src/features/tool-templates/html-css-beautifier-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function HtmlCssBeautifierTool({

React.useEffect(() => {
if (typeof window === "undefined") return
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search))
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search), window.location.hash)
if (!handoff || handoff === appliedHandoffRef.current) return
appliedHandoffRef.current = handoff
setInput(handoff)
Expand Down
2 changes: 1 addition & 1 deletion src/features/tools/html-minifier/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function HtmlMinifierPage() {

React.useEffect(() => {
if (typeof window === "undefined") return
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search))
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search), window.location.hash)
if (!handoff || handoff === appliedHandoffRef.current) return
appliedHandoffRef.current = handoff
setInput(handoff)
Expand Down
2 changes: 1 addition & 1 deletion src/features/tools/javascript-minifier/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function JavascriptMinifierPage() {

React.useEffect(() => {
if (typeof window === "undefined") return
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search))
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search), window.location.hash)
if (!handoff || handoff === appliedHandoffRef.current) return
appliedHandoffRef.current = handoff
setInput(handoff)
Expand Down
2 changes: 1 addition & 1 deletion src/features/tools/json-formatter/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function JsonFormatterPage() {

React.useEffect(() => {
if (typeof window === "undefined") return
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search))
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search), window.location.hash)
if (!handoff || handoff === appliedHandoffRef.current) return
appliedHandoffRef.current = handoff
setInput(handoff)
Expand Down
2 changes: 1 addition & 1 deletion src/features/tools/json-to-typescript/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function JsonToTypeScriptPage() {

React.useEffect(() => {
if (typeof window === "undefined") return
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search))
const handoff = getToolHandoffFromSearchParams(new URLSearchParams(window.location.search), window.location.hash)
if (!handoff || handoff === appliedHandoffRef.current) return
appliedHandoffRef.current = handoff
setInput(handoff)
Expand Down
2 changes: 1 addition & 1 deletion src/features/tools/pipeline-builder/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function PipelineBuilderPage() {
}
}

const handoff = getToolHandoffFromSearchParams(params)
const handoff = getToolHandoffFromSearchParams(params, window.location.hash)
if (handoff) {
setInitialInput(handoff)
toast.success(text("handoff_loaded"))
Expand Down
Loading