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
67 changes: 67 additions & 0 deletions scripts/gates/check-jsonld-structured-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,66 @@ function assertTypes({ scanDir, locale, slug, requiredTypes, label, failures })
}
}

function findNodesByType(value, targetType, out = []) {
if (Array.isArray(value)) {
for (const item of value) findNodesByType(item, targetType, out)
return out
}

if (!value || typeof value !== "object") return out

const record = value
const type = record["@type"]
if (type === targetType || (Array.isArray(type) && type.includes(targetType))) {
out.push(record)
}

for (const child of Object.values(record)) findNodesByType(child, targetType, out)
return out
}

function assertToolWebApplicationFields({ scanDir, locale, slug, label, failures }) {
const filePath = htmlFileFor(scanDir, locale, slug)
if (!fs.existsSync(filePath)) {
failures.push(`${label}: missing built HTML at ${path.relative(process.cwd(), filePath)}`)
return
}

const html = fs.readFileSync(filePath, "utf8")
const blocks = parseJsonLdBlocks(html, filePath, failures)
const webApplications = findNodesByType(blocks, "WebApplication")
const app = webApplications[0]
if (!app) {
failures.push(`${label}: missing WebApplication JSON-LD`)
return
}

const requiredStringFields = [
"name",
"description",
"url",
"applicationCategory",
"operatingSystem",
"browserRequirements",
"inLanguage",
]
for (const field of requiredStringFields) {
if (typeof app[field] !== "string" || app[field].trim().length === 0) {
failures.push(`${label}: WebApplication missing non-empty ${field}`)
}
}

if (app.isAccessibleForFree !== true) {
failures.push(`${label}: WebApplication must mark isAccessibleForFree=true`)
}
if (!app.offers || typeof app.offers !== "object" || app.offers.price !== "0" || app.offers.priceCurrency !== "USD") {
failures.push(`${label}: WebApplication must include free USD Offer`)
}
if (!app.publisher || typeof app.publisher !== "object" || app.publisher["@id"] !== "https://byteflow.tools/#organization") {
failures.push(`${label}: WebApplication must reference the site Organization publisher`)
}
}

function assertUniqueEnglishToolDescriptions(failures) {
const translations = JSON.parse(fs.readFileSync(path.join(process.cwd(), "src/core/i18n/translations/en.json"), "utf8"))
const tools = translations.tools ?? {}
Expand Down Expand Up @@ -212,6 +272,13 @@ function main() {
label: `/en/${slug}`,
failures,
})
assertToolWebApplicationFields({
scanDir,
locale: "en",
slug,
label: `/en/${slug}`,
failures,
})
}

for (const slug of REQUIRED_FAQ_SLUGS) {
Expand Down
122 changes: 122 additions & 0 deletions tests/guards/seo-i18n-schema-acceptance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import sitemap from "@/app/sitemap"
import { LOCALES } from "@/core/i18n/i18n"
import { getTranslation } from "@/core/i18n/translations/catalog"
import { TOOL_REGISTRY } from "@/core/registry"
import { SITE_URL } from "@/core/routing/seo-route-policy"
import { buildCollectionPageJsonLd, buildToolWebApplicationJsonLd, buildWebsiteJsonLd } from "@/core/seo/jsonld"
import { buildLocalizedAlternates } from "@/core/seo/urls"
import { buildHubMetadata, buildStaticPageMetadata, buildToolMetadata } from "@/core/seo/seo"

describe("SEO, schema, and i18n audit acceptance", () => {
it("keeps localized route metadata self-canonical, reciprocal, and social-preview ready", () => {
const toolMetadata = buildToolMetadata({ lang: "de", slug: "json-formatter" })
const hubMetadata = buildHubMetadata({
lang: "fr",
slug: "data-code-formats",
title: "Data & Code Formats",
description: "Format, validate, and convert structured developer data.",
})
const staticMetadata = buildStaticPageMetadata({
lang: "zh-CN",
slug: "trust-center",
title: "Trust Center",
description: "Privacy and verification details for browser-local tools.",
})

expect(toolMetadata.alternates?.canonical).toBe(`${SITE_URL}/de/json-formatter`)
expect(toolMetadata.alternates?.languages).toEqual(buildLocalizedAlternates({ slug: "json-formatter" }))
expect(toolMetadata.openGraph?.images).toEqual([`${SITE_URL}/og/tools/de/json-formatter.jpg`])
expect(toolMetadata.twitter?.images).toEqual([`${SITE_URL}/og/tools/de/json-formatter.jpg`])

expect(hubMetadata.alternates?.canonical).toBe(`${SITE_URL}/fr/data-code-formats`)
expect(hubMetadata.openGraph?.images).toEqual([`${SITE_URL}/og/pages/fr/data-code-formats.jpg`])
expect(hubMetadata.twitter?.images).toEqual([`${SITE_URL}/og/pages/fr/data-code-formats.jpg`])

expect(staticMetadata.alternates?.canonical).toBe(`${SITE_URL}/zh-CN/trust-center`)
expect(staticMetadata.alternates?.languages).toEqual(buildLocalizedAlternates({ slug: "trust-center" }))
expect(staticMetadata.openGraph?.locale).toBe("zh_CN")
})

it("indexes localized sitemap routes for every supported locale and high-value tool routes", () => {
const urls = new Set(sitemap().map((entry) => entry.url))

for (const locale of LOCALES) {
if (locale === "en") {
expect(urls).toContain(`${SITE_URL}/`)
} else {
expect(urls).toContain(`${SITE_URL}/${locale}`)
}

expect(urls).toContain(`${SITE_URL}/${locale}/trust-center`)
expect(urls).toContain(`${SITE_URL}/${locale}/install-app`)
expect(urls).toContain(`${SITE_URL}/${locale}/all-tools`)
expect(urls).toContain(`${SITE_URL}/${locale}/json-formatter`)
expect(urls).toContain(`${SITE_URL}/${locale}/jwt-decoder`)
}
})

it("keeps language switching visible and localized route QA wired into release gates", () => {
const navbarSource = readFileSync("src/components/layout/language-switcher.tsx", "utf8")
const mobileNavbarSource = readFileSync("src/components/layout/navbar-mobile-menu.tsx", "utf8")
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { scripts: Record<string, string> }

expect(navbarSource).toContain("LOCALES.map")
expect(navbarSource).toContain("router.replace")
expect(navbarSource).toContain("buildHomepageHref")
expect(navbarSource).toContain("segments.slice(1)")
expect(mobileNavbarSource).toContain("LOCALE_NAMES")
expect(packageJson.scripts.validate).toContain("check:i18n")
expect(packageJson.scripts.validate).toContain("check:i18n-qa")
expect(packageJson.scripts["build:post"]).toContain("check:hreflang")
expect(packageJson.scripts["build:post"]).toContain("check:metadata-localization")
expect(packageJson.scripts["build:post"]).toContain("check:rendered-i18n-copy")
})

it("emits required structured data fields for website, collection, and representative tools", () => {
const website = buildWebsiteJsonLd("en")
expect(website["@graph"]).toEqual(
expect.arrayContaining([
expect.objectContaining({ "@type": "Organization", url: SITE_URL }),
expect.objectContaining({
"@type": "WebSite",
potentialAction: expect.objectContaining({ "@type": "SearchAction" }),
}),
]),
)

const jsonTool = TOOL_REGISTRY.find((tool) => tool.slug === "json-formatter")
expect(jsonTool).toBeDefined()
const toolSchema = buildToolWebApplicationJsonLd({ lang: "en", tool: jsonTool! })
const toolCopy = getTranslation("en").tools.json_formatter
expect(toolSchema).toMatchObject({
"@type": "WebApplication",
name: toolCopy.title,
description: toolCopy.description,
url: `${SITE_URL}/en/json-formatter`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Any",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
publisher: { "@id": `${SITE_URL}/#organization` },
})

const collectionSchema = buildCollectionPageJsonLd({
lang: "en",
slug: "data-code-formats",
title: "Data & Code Formats",
description: "Format, validate, and convert structured developer data.",
items: [{ name: "JSON Formatter", url: `${SITE_URL}/en/json-formatter` }],
})
expect(collectionSchema["@graph"]).toEqual(
expect.arrayContaining([
expect.objectContaining({ "@type": "CollectionPage" }),
expect.objectContaining({
"@type": "ItemList",
itemListElement: [expect.objectContaining({ "@type": "ListItem", position: 1 })],
}),
]),
)
})
})