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
1 change: 1 addition & 0 deletions app/[lang]/(home)/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
12 changes: 9 additions & 3 deletions app/[lang]/docs/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -125,8 +132,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
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("/")}` : ""}`,
Expand Down
12 changes: 12 additions & 0 deletions lib/seo-copy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -51,6 +55,10 @@
"roadmap": "Реализованные возможности CLI для {messenger}, текущие ограничения и планы развития.",
"changelog": "История выпусков CLI для {messenger}: исправления, изменения и новые возможности.",
"search": "Ищите сообщения в локальном архиве {messenger}: слова, фразы, фильтры, даты и запросы Lucene. Поддерживаемые поля и правила поиска."
},
"toolTitles": {
"tg": "CLI-клиент Telegram для ИИ-агентов",
"max": "MAX CLI для ИИ-агентов и ботов"
}
},
"es": {
Expand Down Expand Up @@ -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"
}
}
}
31 changes: 29 additions & 2 deletions lib/seo.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -73,25 +81,44 @@ export function pageStructuredData({
description,
breadcrumbs = [],
tool,
aboutProject = false,
}: {
lang: string
pathname: string
title: string
description: string
breadcrumbs?: { name: string; pathname: string }[]
tool?: (typeof tools)[number]
aboutProject?: boolean
}) {
const url = absoluteUrl(pathname)
const organizationId = `${siteUrl}/#organization`
const graph: Record<string, unknown>[] = [
{ "@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)
Expand Down
37 changes: 37 additions & 0 deletions scripts/check-seo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand Down
38 changes: 36 additions & 2 deletions scripts/check-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
44 changes: 44 additions & 0 deletions scripts/seo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"
import { StructuredData } from "../components/structured-data"
import {
documentationDescription,
documentationTitle,
pageAlternates,
pageMetadata,
pageStructuredData,
Expand Down Expand Up @@ -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")
})
Expand Down Expand Up @@ -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: "</script><script>alert(1)</script>" } }),
Expand Down
Loading