# Phase 1F-seo — SeoHead + hreflang + JsonLdRenderer Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship the SEO infrastructure — `buildHreflangSet` for 9 languages + x-default, `JsonLdRenderer` with `schema-dts` typing, and `SeoHead` component emitting the full `<head>` shape (title, meta, canonical, hreflang, OG, Twitter, JSON-LD) — so that 1F-layout and all downstream features can render SEO-complete pages with `<SeoHead title={...} hreflang={buildHreflangSet(...)} jsonLd={data} />`.

**Architecture:** Pure functions and thin React components with no runtime dependencies on 1C/1D/1G. `hreflang.ts` builds the 9-language + x-default link set. `json-ld.tsx` serializes `schema-dts` `Thing` objects into safe `<script type="application/ld+json">` blocks. `SeoHead.tsx` composes both into a single `<head>` fragment. The `Language` type is imported from `@/i18n/resolver` (seeded in 1C).

**Tech Stack:** `schema-dts` (Google's Schema.org TypeScript definitions).

**Prerequisites:** 1A-1 (skeleton), 1A-3 (ESLint boundaries), 1C (Language type from `@/i18n/resolver`).

---

## File structure

| File | Responsibility | Task |
|---|---|---|
| `src/shared/seo/hreflang.ts` | `buildHreflangSet` function | 2 |
| `src/shared/seo/hreflang.test.ts` | Tests | 2 |
| `src/shared/seo/json-ld.tsx` | `JsonLdRenderer` + `serializeJsonLd` | 3 |
| `src/shared/seo/json-ld.test.ts` | Tests | 3 |
| `src/ui/seo/SeoHead.tsx` | `<SeoHead>` component | 4 |

---

## Task 1 — Install `schema-dts` dependency

**Files:**
- Modify: `package.json`

- [ ] **Step 1: Install**

```bash
pnpm add schema-dts
```

- [ ] **Step 2: Verify**

```bash
pnpm typecheck
```

- [ ] **Step 3: Commit**

```bash
git add package.json pnpm-lock.yaml
git commit -m "Add schema-dts dependency for typed JSON-LD generation"
```

---

## Task 2 — TDD `hreflang.ts`

**Files:**
- Create: `src/shared/seo/hreflang.ts`
- Create: `src/shared/seo/hreflang.test.ts`

- [ ] **Step 1: Write failing tests**

Create `src/shared/seo/hreflang.test.ts`:

```typescript
import { describe, expect, it } from "vitest";
import { buildHreflangSet } from "./hreflang.js";

describe("buildHreflangSet", () => {
  const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const;

  it("returns entries for all 9 languages plus x-default", () => {
    const result = buildHreflangSet({
      canonicalOrigin: "https://www.aeroflot.ru",
      pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
    });

    expect(result).toHaveLength(10); // 9 languages + x-default
  });

  it("includes all 9 languages", () => {
    const result = buildHreflangSet({
      canonicalOrigin: "https://www.aeroflot.ru",
      pathWithoutLocale: "/smoke",
    });

    const langs = result.map((entry) => entry.lang);
    for (const lang of LANGUAGES) {
      expect(langs).toContain(lang);
    }
  });

  it("x-default points to the ru variant", () => {
    const result = buildHreflangSet({
      canonicalOrigin: "https://www.aeroflot.ru",
      pathWithoutLocale: "/smoke",
    });

    const xDefault = result.find((entry) => entry.lang === "x-default");
    expect(xDefault).toBeDefined();
    expect(xDefault?.href).toBe("https://www.aeroflot.ru/ru/smoke");
  });

  it("builds correct href for each language", () => {
    const result = buildHreflangSet({
      canonicalOrigin: "https://www.aeroflot.ru",
      pathWithoutLocale: "/onlineboard",
    });

    const en = result.find((entry) => entry.lang === "en");
    expect(en?.href).toBe("https://www.aeroflot.ru/en/onlineboard");

    const ja = result.find((entry) => entry.lang === "ja");
    expect(ja?.href).toBe("https://www.aeroflot.ru/ja/onlineboard");
  });

  it("preserves paths with nested segments", () => {
    const result = buildHreflangSet({
      canonicalOrigin: "https://www.aeroflot.ru",
      pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
    });

    const fr = result.find((entry) => entry.lang === "fr");
    expect(fr?.href).toBe("https://www.aeroflot.ru/fr/onlineboard/flight/SU100-2025-01-15");
  });

  it("handles root path", () => {
    const result = buildHreflangSet({
      canonicalOrigin: "https://www.aeroflot.ru",
      pathWithoutLocale: "",
    });

    const ru = result.find((entry) => entry.lang === "ru");
    expect(ru?.href).toBe("https://www.aeroflot.ru/ru");
  });
});
```

- [ ] **Step 2: Run — MUST FAIL**

```bash
pnpm test src/shared/seo/hreflang
```

- [ ] **Step 3: Write implementation**

Create `src/shared/seo/hreflang.ts`:

```typescript
import type { Language } from "@/i18n/resolver";

const LANGUAGES: readonly Language[] = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"];
const X_DEFAULT_LANGUAGE: Language = "ru";

export interface HreflangEntry {
  lang: Language | "x-default";
  href: string;
}

/**
 * Builds the full set of reciprocal hreflang links for a given path.
 * Returns 9 language entries + 1 x-default entry (pointing to ru).
 */
export function buildHreflangSet(args: {
  canonicalOrigin: string;
  pathWithoutLocale: string;
}): HreflangEntry[] {
  const { canonicalOrigin, pathWithoutLocale } = args;

  const entries: HreflangEntry[] = LANGUAGES.map((lang) => ({
    lang,
    href: `${canonicalOrigin}/${lang}${pathWithoutLocale}`,
  }));

  entries.push({
    lang: "x-default",
    href: `${canonicalOrigin}/${X_DEFAULT_LANGUAGE}${pathWithoutLocale}`,
  });

  return entries;
}
```

- [ ] **Step 4: Run — ALL MUST PASS**

```bash
pnpm test src/shared/seo/hreflang
```

- [ ] **Step 5: Typecheck + lint, commit**

```bash
pnpm typecheck && pnpm lint
git add src/shared/seo/hreflang.ts src/shared/seo/hreflang.test.ts
git commit -m "Add buildHreflangSet for 9 languages + x-default"
```

---

## Task 3 — TDD `json-ld.tsx`

**Files:**
- Create: `src/shared/seo/json-ld.tsx`
- Create: `src/shared/seo/json-ld.test.ts`

- [ ] **Step 1: Write failing tests**

Create `src/shared/seo/json-ld.test.ts`:

```typescript
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
import type { Thing } from "schema-dts";
import { JsonLdRenderer, serializeJsonLd } from "./json-ld.js";

describe("serializeJsonLd", () => {
  it("serializes a single Thing to a JSON-LD string", () => {
    const data: Thing = {
      "@type": "WebSite",
      name: "Aeroflot",
      url: "https://www.aeroflot.ru",
    };

    const result = serializeJsonLd(data);
    const parsed = JSON.parse(result);

    expect(parsed["@context"]).toBe("https://schema.org");
    expect(parsed["@type"]).toBe("WebSite");
    expect(parsed.name).toBe("Aeroflot");
  });

  it("serializes an array of Things with @context on each", () => {
    const data: Thing[] = [
      { "@type": "WebSite", name: "Aeroflot" } as Thing,
      { "@type": "Organization", name: "Aeroflot PJSC" } as Thing,
    ];

    const result = serializeJsonLd(data);
    const parsed = JSON.parse(result);

    expect(Array.isArray(parsed)).toBe(true);
    expect(parsed).toHaveLength(2);
    expect(parsed[0]["@context"]).toBe("https://schema.org");
    expect(parsed[1]["@context"]).toBe("https://schema.org");
  });

  it("escapes </script> to prevent injection", () => {
    const data: Thing = {
      "@type": "WebSite",
      name: '</script><script>alert("xss")</script>',
    };

    const result = serializeJsonLd(data);
    expect(result).not.toContain("</script>");
  });
});

describe("JsonLdRenderer", () => {
  it("renders a <script type=application/ld+json> tag", () => {
    const data: Thing = {
      "@type": "WebSite",
      name: "Aeroflot",
      url: "https://www.aeroflot.ru",
    };

    const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));

    expect(html).toContain('<script type="application/ld+json">');
    expect(html).toContain("</script>");
    expect(html).toContain('"@context":"https://schema.org"');
    expect(html).toContain('"@type":"WebSite"');
  });

  it("round-trips: serialize → DOM string contains valid JSON-LD", () => {
    const data: Thing = {
      "@type": "Organization",
      name: "Aeroflot PJSC",
      url: "https://www.aeroflot.ru",
    };

    const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));

    // Extract JSON from the script tag
    const match = html.match(/<script[^>]*>([\s\S]*?)<\/script>/);
    expect(match).not.toBeNull();

    const json = match![1]!.replace(/\\u003c/g, "<");
    const parsed = JSON.parse(json);
    expect(parsed["@context"]).toBe("https://schema.org");
    expect(parsed["@type"]).toBe("Organization");
    expect(parsed.name).toBe("Aeroflot PJSC");
  });
});
```

- [ ] **Step 2: Run — MUST FAIL**

```bash
pnpm test src/shared/seo/json-ld
```

- [ ] **Step 3: Write implementation**

Create `src/shared/seo/json-ld.tsx`:

```tsx
import type { Thing } from "schema-dts";

export interface JsonLdRendererProps {
  data: Thing | Thing[];
}

/**
 * Serializes a schema-dts Thing (or array of Things) to a JSON-LD string.
 * Adds "@context": "https://schema.org" to each item.
 * Escapes </script> sequences to prevent XSS.
 */
export function serializeJsonLd(data: Thing | Thing[]): string {
  const withContext = Array.isArray(data)
    ? data.map((item) => ({ "@context": "https://schema.org" as const, ...item }))
    : { "@context": "https://schema.org" as const, ...data };

  return JSON.stringify(withContext).replace(/<\//g, "\\u003c/");
}

/**
 * Renders a <script type="application/ld+json"> block with the serialized
 * JSON-LD data. Safe for SSR — the content is escaped against script injection.
 */
export function JsonLdRenderer({ data }: JsonLdRendererProps): JSX.Element {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
    />
  );
}
```

- [ ] **Step 4: Run — ALL MUST PASS**

```bash
pnpm test src/shared/seo/json-ld
```

- [ ] **Step 5: Typecheck + lint, commit**

```bash
pnpm typecheck && pnpm lint
git add src/shared/seo/json-ld.tsx src/shared/seo/json-ld.test.ts
git commit -m "Add JsonLdRenderer and serializeJsonLd with schema-dts typing"
```

---

## Task 4 — Create `SeoHead.tsx` (no TDD)

**Files:**
- Create: `src/ui/seo/SeoHead.tsx`

No TDD — thin React component assembling head tags. Tested by 1F-layout integration.

- [ ] **Step 1: Write implementation**

Create `src/ui/seo/SeoHead.tsx`:

```tsx
import type { Language } from "@/i18n/resolver";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import type { Thing } from "schema-dts";

export interface SeoHeadProps {
  title: string;
  description: string;
  canonical: string;
  hreflang: Array<{ lang: Language | "x-default"; href: string }>;
  og: {
    title: string;
    description: string;
    url: string;
    image: string;
    type: "website" | "article";
    locale: string;
    siteName: string;
  };
  twitter?: {
    card: "summary" | "summary_large_image";
    title?: string;
    description?: string;
    image?: string;
  };
  jsonLd?: Thing | Thing[];
  noindex?: boolean;
}

/**
 * Renders the full SEO <head> fragment: title, meta description, canonical,
 * hreflang alternates, Open Graph tags, Twitter Card tags, and JSON-LD.
 */
export function SeoHead({
  title,
  description,
  canonical,
  hreflang,
  og,
  twitter,
  jsonLd,
  noindex,
}: SeoHeadProps): JSX.Element {
  return (
    <>
      <title>{title}</title>
      <meta name="description" content={description} />
      <link rel="canonical" href={canonical} />
      {noindex && <meta name="robots" content="noindex,nofollow" />}

      {/* Hreflang alternates */}
      {hreflang.map((entry) => (
        <link
          key={entry.lang}
          rel="alternate"
          hrefLang={entry.lang}
          href={entry.href}
        />
      ))}

      {/* Open Graph */}
      <meta property="og:title" content={og.title} />
      <meta property="og:description" content={og.description} />
      <meta property="og:url" content={og.url} />
      <meta property="og:image" content={og.image} />
      <meta property="og:type" content={og.type} />
      <meta property="og:locale" content={og.locale} />
      <meta property="og:site_name" content={og.siteName} />

      {/* Twitter Card */}
      {twitter && (
        <>
          <meta name="twitter:card" content={twitter.card} />
          {twitter.title && <meta name="twitter:title" content={twitter.title} />}
          {twitter.description && <meta name="twitter:description" content={twitter.description} />}
          {twitter.image && <meta name="twitter:image" content={twitter.image} />}
        </>
      )}

      {/* JSON-LD */}
      {jsonLd && <JsonLdRenderer data={jsonLd} />}
    </>
  );
}
```

- [ ] **Step 2: Typecheck + lint, commit**

```bash
pnpm typecheck && pnpm lint
git add src/ui/seo/SeoHead.tsx
git commit -m "Add SeoHead component with canonical, hreflang, OG, Twitter, and JSON-LD"
```

---

## Task 5 — Exit-gate verification

- [ ] **Step 1: All gates**

```bash
pnpm typecheck && pnpm lint && pnpm test
```

Expected: all pass. hreflang tests cover 9 languages + x-default. JsonLdRenderer round-trips through serialize to DOM string.

- [ ] **Step 2: Git status clean**

```bash
git status
```

---

## Self-review

**Spec coverage.** Master plan §1F-seo:
- `buildHreflangSet` with 9 languages + `x-default` (x-default → ru) → Task 2
- `JsonLdRenderer` + `serializeJsonLd` with `schema-dts` `Thing` type → Task 3
- `SeoHead` with title, meta description, canonical, hreflang links, OG tags, Twitter Card, JSON-LD, noindex → Task 4
- XSS protection via `</script>` escaping in `serializeJsonLd` → Task 3

**Exit gate alignment:**
- "buildHreflangSet covers 9 langs + x-default" — Task 2 tests
- "SeoHead emits the full <head> shape" — Task 4 component (tested by 1F-layout integration)
- "JsonLdRenderer round-trips a typed Thing through serializeJsonLd → DOM string" — Task 3 tests

**Type consistency.** `Language` from `@/i18n/resolver` (seeded in 1C). `Thing` from `schema-dts`. `SeoHeadProps` matches the master plan contract exactly.
