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
140 changes: 140 additions & 0 deletions e2e/seo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { test, expect, type Page } from "@playwright/test";

const SITE_URL = "https://weftmap.vercel.app";

declare global {
interface Window {
__cspViolations: string[];
}
}

// The CSP is built per request in src/proxy.ts and has no 'unsafe-inline' in
// either dev or production — only 'unsafe-eval' differs — so the nonce path
// this test exercises is the same one that runs in production.
async function collectCspViolations(page: Page, path: string) {
await page.addInitScript(() => {
window.__cspViolations = [];
document.addEventListener("securitypolicyviolation", (event) => {
window.__cspViolations.push(
`${event.violatedDirective} blocked ${event.blockedURI}`,
);
});
});
await page.goto(path);
return page.evaluate(() => window.__cspViolations);
}

test("homepage emits JSON-LD that the CSP does not block", async ({ page }) => {
const violations = await collectCspViolations(page, "/en");
expect(violations).toEqual([]);

// The nonce has to be checked in the server HTML, not the DOM: browsers blank
// the attribute once the CSP is applied, so page.locator() always sees "".
const response = await page.request.get("/en");
const html = await response.text();
const csp = response.headers()["content-security-policy"];
const scriptNonce = html.match(
/<script type="application\/ld\+json" nonce="([^"]+)"/,
)?.[1];
expect(scriptNonce, "JSON-LD script carries a nonce").toBeTruthy();
expect(csp).toContain(`nonce-${scriptNonce}`);

const script = page.locator('script[type="application/ld+json"]');
await expect(script).toHaveCount(1);

const types = await script.evaluate((el) => {
const parsed = JSON.parse(el.textContent ?? "[]");
return (Array.isArray(parsed) ? parsed : [parsed]).map(
(node: { "@type": string }) => node["@type"],
);
});
expect(types).toEqual([
"SoftwareApplication",
"Organization",
"FAQPage",
]);
});

test("FAQ structured data matches the questions rendered on the page", async ({
page,
}) => {
await page.goto("/en");

const schemaQuestions = await page
.locator('script[type="application/ld+json"]')
.evaluate((el) => {
const nodes = JSON.parse(el.textContent ?? "[]");
const faq = nodes.find(
(node: { "@type": string }) => node["@type"] === "FAQPage",
);
return faq.mainEntity.map((q: { name: string }) => q.name);
});

expect(schemaQuestions.length).toBeGreaterThan(0);
for (const question of schemaQuestions) {
// The question sits in a <summary> next to a "+" glyph, so match on the
// summary containing it rather than on exact text.
await expect(
page.locator("#faq summary").filter({ hasText: question }),
).toBeVisible();
}
});

test("docs page emits TechArticle and breadcrumbs", async ({ page }) => {
const violations = await collectCspViolations(page, "/es/docs/languages");
expect(violations).toEqual([]);

const types = await page
.locator('script[type="application/ld+json"]')
.evaluate((el) =>
JSON.parse(el.textContent ?? "[]").map(
(node: { "@type": string }) => node["@type"],
),
);
expect(types).toEqual(["TechArticle", "BreadcrumbList"]);
});

test("indexable pages carry canonical, hreflang and a social card", async ({
page,
}) => {
await page.goto("/es/docs/languages");

await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
"href",
`${SITE_URL}/es/docs/languages`,
);
await expect(page.locator('meta[property="og:url"]')).toHaveAttribute(
"content",
`${SITE_URL}/es/docs/languages`,
);
await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute(
"content",
"summary_large_image",
);
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
new RegExp(`^${SITE_URL}/es/opengraph-image`),
);

// Six locales plus x-default, all pointing at the same document.
await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(7);

const description = await page
.locator('meta[name="description"]')
.getAttribute("content");
expect(description?.length).toBeGreaterThan(0);
});

test("auth-gated routes are noindex and carry no structured data", async ({
page,
}) => {
await page.goto("/en/graphs");

await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
"content",
/noindex/,
);
await expect(
page.locator('script[type="application/ld+json"]'),
).toHaveCount(0);
});
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Playwright output. Gitignored, but eslint doesn't read .gitignore, so
// running the e2e suite before lint would otherwise flood it with errors
// from bundled report assets.
"playwright-report/**",
"test-results/**",
]),
]);

Expand Down
11 changes: 6 additions & 5 deletions src/app/[lang]/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@ import { isLocale } from "@/i18n/config";
import { auth } from "@/auth";
import CodeWorkspace from "@/components/ui/CodeWorkspace";

import { getAlternates } from "@/lib/seo";
import { buildMetadata } from "@/lib/seo";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
return {
title: "Weftmap — Editor",
alternates: getAlternates("app", lang),
};
return buildMetadata({
lang: isLocale(lang) ? lang : "en",
path: "app",
seoKey: "app",
});
}

export default async function AppPage({
Expand Down
150 changes: 150 additions & 0 deletions src/app/[lang]/call-graph/[language]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { isLocale, locales, type Locale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { LANDING_LANGUAGES, getLandingLanguage } from "@/lib/landing-languages";
import { buildMetadata, getSeoEntry } from "@/lib/seo";
import { buildBreadcrumbs } from "@/lib/structured-data";
import { CodeBlock } from "@/components/docs/Prose";
import JsonLd from "@/components/seo/JsonLd";

// Statically generated for the full locale x language cross product, so a
// crawler gets complete HTML with nothing deferred to hydration.
export function generateStaticParams() {
return locales.flatMap((lang) =>
LANDING_LANGUAGES.map(({ id }) => ({ lang, language: id })),
);
}

type Params = Promise<{ lang: string; language: string }>;

export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { lang, language } = await params;
const entry = getLandingLanguage(language);
if (!isLocale(lang) || !entry) return {};

return buildMetadata({
lang,
path: `call-graph/${entry.id}`,
seoKey: `landing.${entry.id}`,
});
}

export default async function CallGraphLandingPage({
params,
}: {
params: Params;
}) {
const { lang, language } = await params;
if (!isLocale(lang)) notFound();

const entry = getLandingLanguage(language);
if (!entry) notFound();

const t = getDictionary(lang as Locale);
const copy = t.landing.pages[entry.id];
const { title } = getSeoEntry(lang, `landing.${entry.id}`);

const others = LANDING_LANGUAGES.filter((l) => l.id !== entry.id);

return (
<main className="mx-auto w-full max-w-[820px] px-6 py-16 sm:py-24">
{/* Two levels only: there is no /call-graph index page, and a breadcrumb
pointing at a URL that doesn't exist is worse than a shorter trail. */}
<JsonLd
data={buildBreadcrumbs(lang, [
{ name: "Weftmap", path: "" },
{ name: title, path: `call-graph/${entry.id}` },
])}
/>

<span className="block font-mono text-[12px] tracking-[0.28em] text-[#94a3b8]">
{entry.name.toUpperCase()}
</span>
<h1 className="mt-4 text-[clamp(2.2rem,4vw,3.2rem)] font-bold leading-[1.06] tracking-[-0.025em] text-[#0f172a] dark:text-[#e6e9ef]">
{copy.h1}
</h1>
<p className="mt-6 max-w-[52ch] text-[1.05rem] leading-relaxed text-[#475569] dark:text-[#9aa6b8]">
{copy.intro}
</p>

<Link
href={`/${lang}/app`}
className="mt-8 inline-block rounded-full bg-[#4f46e5] dark:bg-[#6366f1] px-7 py-3.5 text-base font-semibold text-white shadow-[0_8px_24px_-8px_rgba(79,70,229,0.6)] transition hover:-translate-y-px hover:bg-[#4338ca]"
>
{t.landing.ctaButton}
</Link>

<section className="mt-16">
<h2 className="border-b border-[#e2e8f0] dark:border-[#232a36] pb-2 text-xl font-semibold tracking-[-0.01em] text-[#0f172a] dark:text-[#e6e9ef]">
{t.landing.exampleHeading}
</h2>
{/* dir="ltr" so the snippet stays left-to-right inside the RTL Arabic
layout — code is not prose. */}
<div dir="ltr">
<CodeBlock label={`example.${entry.extension}`}>
{entry.snippet}
</CodeBlock>
</div>
</section>

<section className="mt-14">
<h2 className="border-b border-[#e2e8f0] dark:border-[#232a36] pb-2 text-xl font-semibold tracking-[-0.01em] text-[#0f172a] dark:text-[#e6e9ef]">
{t.landing.showsHeading}
</h2>
<p className="mt-5 max-w-[62ch] text-[15px] leading-7 text-[#475569] dark:text-[#9aa6b8]">
{copy.shows}
</p>
<div className="mt-6 flex flex-wrap gap-x-6 gap-y-2 text-[14px]">
<Link
href={`/${lang}/docs/reading-the-diagram`}
className="text-[#4f46e5] underline-offset-4 hover:underline dark:text-[#818cf8]"
>
{t.landing.docsReadDiagram}
</Link>
<Link
href={`/${lang}/docs/languages`}
className="text-[#4f46e5] underline-offset-4 hover:underline dark:text-[#818cf8]"
>
{t.landing.docsLanguages}
</Link>
</div>
</section>

<section className="mt-16 rounded-2xl border border-[#e2e8f0] dark:border-[#232a36] bg-[#f8fafc] dark:bg-[#12151c] p-8">
<h2 className="text-[1.35rem] font-semibold tracking-[-0.01em] text-[#0f172a] dark:text-[#e6e9ef]">
{t.landing.ctaTitle}
</h2>
<p className="mt-3 max-w-[48ch] text-[15px] leading-7 text-[#475569] dark:text-[#9aa6b8]">
{t.landing.ctaDesc}
</p>
<Link
href={`/${lang}/app`}
className="mt-6 inline-block rounded-full bg-[#4f46e5] dark:bg-[#6366f1] px-7 py-3 text-[15px] font-semibold text-white transition hover:-translate-y-px hover:bg-[#4338ca]"
>
{t.landing.ctaButton}
</Link>
</section>

<nav
aria-label={t.landing.docsLanguages}
className="mt-14 flex flex-wrap gap-2 border-t border-[#e2e8f0] dark:border-[#232a36] pt-8"
>
{others.map((other) => (
<Link
key={other.id}
href={`/${lang}/call-graph/${other.id}`}
className="rounded-md border border-[#e2e8f0] dark:border-[#232a36] bg-white dark:bg-[#12151c] px-3 py-1.5 font-mono text-xs text-[#475569] dark:text-[#9aa6b8] transition-colors hover:border-[#4f46e5] hover:text-[#0f172a] dark:hover:text-[#e6e9ef]"
>
{other.name}
</Link>
))}
</nav>
</main>
);
}
28 changes: 21 additions & 7 deletions src/app/[lang]/docs/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,30 @@ import { isLocale, locales, type Locale } from "@/i18n/config";
import { getDictionary } from "@/i18n/dictionaries";
import { DOC_NAV, DOC_SLUGS, type DocSlug } from "@/lib/docs";
import { DOC_COMPONENTS } from "@/components/docs/registry";
import JsonLd from "@/components/seo/JsonLd";
import { buildBreadcrumbs, buildTechArticle } from "@/lib/structured-data";
import { getSeoEntry } from "@/lib/seo";

export function generateStaticParams() {
return locales.flatMap((lang) => DOC_SLUGS.map((slug) => ({ lang, slug })));
}

import { getAlternates } from "@/lib/seo";
import { buildMetadata } from "@/lib/seo";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}): Promise<Metadata> {
const { lang, slug } = await params;
const item = DOC_NAV.find((d) => d.slug === slug);
const locale: Locale = isLocale(lang) ? lang : "en";
const title = item ? (item.title[locale] ?? item.title["en"]) : "Docs";
return {
title: `${title} — Weftmap`,
alternates: getAlternates(`docs/${slug}`, lang),
};
if (!DOC_SLUGS.includes(slug as DocSlug)) return {};

return buildMetadata({
lang: locale,
path: `docs/${slug}`,
seoKey: `docs.${slug as DocSlug}`,
});
}

export default async function DocPage({
Expand All @@ -45,6 +49,16 @@ export default async function DocPage({

return (
<article>
<JsonLd
data={[
buildTechArticle(lang, slug as DocSlug),
buildBreadcrumbs(lang, [
{ name: "Weftmap", path: "" },
{ name: t.documentation, path: "docs/introduction" },
{ name: getSeoEntry(lang, `docs.${slug as DocSlug}`).title, path: `docs/${slug}` },
]),
]}
/>
<Content lang={lang} />

<nav className="mt-16 grid grid-cols-1 gap-4 border-t border-[#e2e8f0] pt-8 sm:grid-cols-2">
Expand Down
Loading
Loading