Files
flights_web/docs/superpowers/plans/2026-04-14-phase-1f-seo.md
T

14 KiB

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

pnpm add schema-dts
  • Step 2: Verify
pnpm typecheck
  • Step 3: Commit
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:

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
pnpm test src/shared/seo/hreflang
  • Step 3: Write implementation

Create src/shared/seo/hreflang.ts:

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
pnpm test src/shared/seo/hreflang
  • Step 5: Typecheck + lint, commit
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:

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
pnpm test src/shared/seo/json-ld
  • Step 3: Write implementation

Create src/shared/seo/json-ld.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
pnpm test src/shared/seo/json-ld
  • Step 5: Typecheck + lint, commit
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:

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
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
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
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.