diff --git a/app/[lang]/(home)/about/page.tsx b/app/[lang]/(home)/about/page.tsx index b769785..2d5c133 100644 --- a/app/[lang]/(home)/about/page.tsx +++ b/app/[lang]/(home)/about/page.tsx @@ -27,6 +27,7 @@ export default async function AboutPage({ params }: Props) { data={pageStructuredData({ lang, pathname: `/${lang}/about`, + aboutProject: true, title: words.title, description: words.intro, breadcrumbs: [ diff --git a/app/[lang]/docs/[[...slug]]/page.tsx b/app/[lang]/docs/[[...slug]]/page.tsx index 85916be..e1f108c 100644 --- a/app/[lang]/docs/[[...slug]]/page.tsx +++ b/app/[lang]/docs/[[...slug]]/page.tsx @@ -16,7 +16,14 @@ import { DocsTocPopover } from "@/components/docs-toc-popover" import { getMDXComponents } from "@/components/mdx" import { StructuredData } from "@/components/structured-data" import { installationReferenceTitle, ToolInstallationIntro } from "@/components/tool-installation-intro" -import { documentationDescription, pageMetadata, pageStructuredData, seoLocales, seoWords } from "@/lib/seo" +import { + documentationDescription, + documentationTitle, + pageMetadata, + pageStructuredData, + seoLocales, + seoWords, +} from "@/lib/seo" import { appName, getPageMarkdownUrl, toolOf } from "@/lib/shared" import { source } from "@/lib/source" import { wordsFor } from "@/lib/words" @@ -125,8 +132,7 @@ export async function generateMetadata(props: Props): Promise { const { slug, lang } = await props.params const page = source.getPage(slug, lang) if (!page) notFound() - const tool = toolOf(page.slugs) - const title = tool && page.slugs.length > 1 ? `${page.data.title} — ${tool.name}` : page.data.title + const title = documentationTitle(lang, page.slugs, page.data.title) return pageMetadata({ lang, suffix: `/docs${page.slugs.length ? `/${page.slugs.join("/")}` : ""}`, diff --git a/lib/seo-copy.json b/lib/seo-copy.json index 43582d6..ca2cd5f 100644 --- a/lib/seo-copy.json +++ b/lib/seo-copy.json @@ -24,6 +24,10 @@ "roadmap": "Review implemented {messenger} CLI capabilities, current limitations and planned development.", "changelog": "Read release notes and changes in the {messenger} CLI, including fixes and new capabilities.", "search": "Search your local {messenger} message archive with words, phrases, filters, dates and Lucene queries; learn the supported fields and matching rules." + }, + "toolTitles": { + "tg": "Telegram CLI client for AI agents", + "max": "MAX CLI for AI agents and bots" } }, "ru": { @@ -51,6 +55,10 @@ "roadmap": "Реализованные возможности CLI для {messenger}, текущие ограничения и планы развития.", "changelog": "История выпусков CLI для {messenger}: исправления, изменения и новые возможности.", "search": "Ищите сообщения в локальном архиве {messenger}: слова, фразы, фильтры, даты и запросы Lucene. Поддерживаемые поля и правила поиска." + }, + "toolTitles": { + "tg": "CLI-клиент Telegram для ИИ-агентов", + "max": "MAX CLI для ИИ-агентов и ботов" } }, "es": { @@ -78,6 +86,10 @@ "roadmap": "Revisa las capacidades disponibles, las limitaciones actuales y los planes de desarrollo de la CLI de {messenger}.", "changelog": "Consulta las notas de versión de la CLI de {messenger}, con correcciones, cambios y nuevas capacidades.", "search": "Busca mensajes en tu archivo local de {messenger} con palabras, frases, filtros, fechas y consultas Lucene; consulta los campos y las reglas de búsqueda." + }, + "toolTitles": { + "tg": "Cliente CLI de Telegram para agentes de IA", + "max": "CLI de MAX para agentes de IA y bots" } } } diff --git a/lib/seo.ts b/lib/seo.ts index aaa1530..30d4417 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next" +import siteConfig from "../site.config.json" import seoCopy from "./seo-copy.json" import { appName, siteUrl, tools } from "./shared" @@ -57,6 +58,13 @@ export function pageMetadata({ } } +export function documentationTitle(lang: string, slugs: string[], authored: string): string { + const tool = tools.find((candidate) => candidate.name === slugs[0]) + if (!tool) return authored + if (slugs.length === 1) return seoWords(lang).toolTitles[tool.name as "tg" | "max"] + return `${authored} — ${tool.name}` +} + export function documentationDescription(lang: string, slugs: string[], authored?: string): string { if (authored?.trim()) return authored const [tool, slug] = slugs @@ -73,6 +81,7 @@ export function pageStructuredData({ description, breadcrumbs = [], tool, + aboutProject = false, }: { lang: string pathname: string @@ -80,18 +89,36 @@ export function pageStructuredData({ description: string breadcrumbs?: { name: string; pathname: string }[] tool?: (typeof tools)[number] + aboutProject?: boolean }) { const url = absoluteUrl(pathname) + const organizationId = `${siteUrl}/#organization` const graph: Record[] = [ - { "@type": "WebSite", "@id": `${siteUrl}/#website`, url: siteUrl, name: appName, inLanguage: seoLocales }, { - "@type": tool ? "TechArticle" : "WebPage", + "@type": ["Organization", "Project"], + "@id": organizationId, + name: appName, + url: siteUrl, + logo: absoluteUrl("/android-chrome-512x512.png"), + email: siteConfig.contacts.email, + }, + { + "@type": "WebSite", + "@id": `${siteUrl}/#website`, + url: siteUrl, + name: appName, + inLanguage: seoLocales, + publisher: { "@id": organizationId }, + }, + { + "@type": tool ? "TechArticle" : aboutProject ? "AboutPage" : "WebPage", "@id": `${url}#page`, url, name: title, description, inLanguage: lang, isPartOf: { "@id": `${siteUrl}/#website` }, + ...(aboutProject ? { mainEntity: { "@id": organizationId } } : {}), }, ] if (breadcrumbs.length > 1) diff --git a/scripts/check-seo.test.ts b/scripts/check-seo.test.ts index f0db993..b67781f 100644 --- a/scripts/check-seo.test.ts +++ b/scripts/check-seo.test.ts @@ -29,6 +29,25 @@ describe("production SEO guard", () => { ), ) } + const identity = { + "@type": ["Organization", "Project"], + "@id": "https://example.test/#organization", + name: "WireCat", + url: "https://example.test", + logo: "https://example.test/logo.png", + } + const website = { "@type": "WebSite", publisher: { "@id": identity["@id"] } } + for (const lang of ["en", "ru"]) { + const file = join(out, `${lang}.html`) + writeFileSync( + file, + readFileSync(file, "utf8").replace( + '"@graph":[', + `"@graph":[${JSON.stringify(identity)},${JSON.stringify(website)},`, + ), + ) + } + write("logo.png", "fixture") write("og.png", "fixture") write( "sitemap.xml", @@ -48,6 +67,24 @@ describe("production SEO guard", () => { } expect(seoProblems(out, "https://example.test", ["en", "ru"])).toEqual([]) }) + it("rejects an unrelated publisher and a missing project logo", () => { + const file = join(out, "en.html") + writeFileSync( + file, + readFileSync(file, "utf8") + .replace( + '"publisher":{"@id":"https://example.test/#organization"}', + '"publisher":{"@id":"https://other.test/#organization"}', + ) + .replace('"logo":"https://example.test/logo.png"', '"logo":"https://example.test/missing.png"'), + ) + expect(seoProblems(out, "https://example.test", ["en", "ru"])).toEqual( + expect.arrayContaining([ + "/en: website publisher must reference WireCat", + "/en: organization logo must be an available local image", + ]), + ) + }) it("rejects a canonical and social field accidentally inherited from another page", () => { const file = join(out, "ru.html") writeFileSync( diff --git a/scripts/check-seo.ts b/scripts/check-seo.ts index 85ff1f2..35ce539 100644 --- a/scripts/check-seo.ts +++ b/scripts/check-seo.ts @@ -61,14 +61,48 @@ export function seoProblems(out: string, origin: string, locales = ["en", "ru", if (!schemas.length) fail("missing page structured data") for (const [, json] of schemas) { try { - const data = JSON.parse(json) as { "@context"?: string; "@graph"?: { "@type"?: string; url?: string }[] } + const data = JSON.parse(json) as { + "@context"?: string + "@graph"?: { + "@type"?: string | string[] + "@id"?: string + url?: string + name?: string + logo?: string + publisher?: { "@id"?: string } + mainEntity?: { "@id"?: string } + }[] + } if (data["@context"] !== "https://schema.org") fail("unexpected structured-data context") if ( !data["@graph"]?.some( - (node) => ["WebPage", "TechArticle"].includes(node["@type"] ?? "") && node.url === `${origin}${pathname}`, + (node) => + typeof node["@type"] === "string" && + ["WebPage", "AboutPage", "TechArticle"].includes(node["@type"]) && + node.url === `${origin}${pathname}`, ) ) fail("structured data must identify this page") + const organization = data["@graph"]?.find( + (node) => Array.isArray(node["@type"]) && node["@type"].includes("Organization"), + ) + if ( + organization?.["@id"] !== `${origin}/#organization` || + organization.name !== "WireCat" || + organization.url !== origin + ) + fail("missing consistent WireCat organization identity") + const website = data["@graph"]?.find((node) => node["@type"] === "WebSite") + if (website?.publisher?.["@id"] !== organization?.["@id"]) fail("website publisher must reference WireCat") + if ( + !organization?.logo?.startsWith(`${origin}/`) || + !existsSync(join(out, new URL(organization.logo).pathname)) + ) + fail("organization logo must be an available local image") + if (pathname.endsWith("/about")) { + const about = data["@graph"]?.find((node) => node["@type"] === "AboutPage") + if (about?.mainEntity?.["@id"] !== organization?.["@id"]) fail("About must describe the WireCat identity") + } } catch { fail("invalid JSON-LD") } diff --git a/scripts/seo.test.ts b/scripts/seo.test.ts index 66a4cdc..ab6f5f3 100644 --- a/scripts/seo.test.ts +++ b/scripts/seo.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest" import { StructuredData } from "../components/structured-data" import { documentationDescription, + documentationTitle, pageAlternates, pageMetadata, pageStructuredData, @@ -36,6 +37,18 @@ describe("localized SEO", () => { }) }) + it.each(seoLocales)( + "describes product intent on provider entry pages without relabeling shared guides in %s", + (lang) => { + expect(documentationTitle(lang, ["tg"], "Telegram")).toContain("Telegram") + expect(documentationTitle(lang, ["tg"], "Telegram")).toContain("CLI") + expect(documentationTitle(lang, ["max"], "MAX")).toContain("MAX") + expect(documentationTitle(lang, ["max"], "MAX")).toContain("CLI") + expect(documentationTitle(lang, ["agents"], "Connect your agent")).toBe("Connect your agent") + expect(documentationTitle(lang, ["tg", "search"], "Search")).toBe("Search — tg") + }, + ) + it("does not announce a locale whose equivalent page is unavailable", () => { expect(pageAlternates("ru", "/docs/tg/new", ["en", "ru"]).languages).not.toHaveProperty("es") }) @@ -107,6 +120,37 @@ describe("truthful structured data", () => { expect(JSON.stringify(data)).not.toMatch(/aggregateRating|reviewCount|downloadCount/) }) + it.each(seoLocales)("identifies the same factual WireCat project and publisher in %s", (lang) => { + for (const aboutProject of [false, true]) { + const data = pageStructuredData({ + lang, + pathname: `/${lang}${aboutProject ? "/about" : ""}`, + title: "WireCat", + description: "Open-source tools", + aboutProject, + }) + const organization = data["@graph"].find( + (node) => Array.isArray(node["@type"]) && node["@type"].includes("Organization"), + ) + expect(organization).toMatchObject({ + "@type": ["Organization", "Project"], + "@id": "https://wirecat.dev/#organization", + name: "WireCat", + url: "https://wirecat.dev", + logo: "https://wirecat.dev/android-chrome-512x512.png", + email: "hello@wirecat.dev", + }) + expect(data["@graph"].find((node) => node["@type"] === "WebSite")).toMatchObject({ + publisher: { "@id": organization?.["@id"] }, + }) + if (aboutProject) + expect(data["@graph"].find((node) => node["@type"] === "AboutPage")).toMatchObject({ + mainEntity: { "@id": organization?.["@id"] }, + }) + expect(JSON.stringify(data)).not.toMatch(/"Person"|legalName|founder|taxID|aggregateRating/) + } + }) + it("cannot close its script element through a metadata string", () => { const html = renderToStaticMarkup( createElement(StructuredData, { data: { name: "" } }),