brains
← All skills

Tour Site SEO

seo

tour-site-seo

SEO and answer-engine optimization for Next.js App Router tour, event, and artist fan sites — programmatic city/venue pages, MusicEvent + FAQPage JSON-LD, canonical/OpenGraph metadata, llms.txt, AI-crawler robots rules, freshness signals, and Core Web Vitals. Use when building a new tour or event site, adding city/venue pages, auditing an existing one, or when asked to "improve SEO", "add schema", "fix metadata", "rank for city searches", or "get cited by ChatGPT/Perplexity".

Install

$ brains install tour-site-seo

Installs to ~/.claude/skills/tour-site-seo/. Add --project to install into the current repository instead.

Version
1.0.0
Installs
4
Size
71.8 KB
Updated
Jul 29, 2026
Author
Brains
License
MIT
Files
7
Entry
SKILL.md

Contents

SKILL.md8.2 KB
---
name: tour-site-seo
description: SEO and answer-engine optimization for Next.js App Router tour, event, and artist fan sites — programmatic city/venue pages, MusicEvent + FAQPage JSON-LD, canonical/OpenGraph metadata, llms.txt, AI-crawler robots rules, freshness signals, and Core Web Vitals. Use when building a new tour or event site, adding city/venue pages, auditing an existing one, or when asked to "improve SEO", "add schema", "fix metadata", "rank for city searches", or "get cited by ChatGPT/Perplexity".
---

# Tour & event site SEO

Reference implementation: `artist-tours/shreyaghoshaltour` (Next.js 16 App Router, React 19,
Tailwind v4, data from the AllEvents API). Sibling sites `brunomarstour` and `btstour` share
the architecture. Everything below is extracted from that working site, not from generic advice.

**Before writing Next.js code, read the relevant guide in `node_modules/next/dist/docs/`.**
These repos pin Next 16, whose metadata APIs differ from older training data.

## The model this site is built on

A tour site competes for three distinct query shapes, and each needs different work:

| Query shape | Example | What wins it |
|---|---|---|
| Head term | "shreya ghoshal tour 2026" | Homepage: live show count + date range in the title/description, `MusicEvent[]` + `WebSite` + `Person` JSON-LD, freshness |
| City intent | "shreya ghoshal chicago tickets" | A dedicated page whose **slug is the metro people type**, with unique venue + transit prose and single-event JSON-LD |
| Conversational | "when is shreya ghoshal playing near me" | Extractable prose (QuickAnswer block), standalone FAQ answers, `llms.txt`, AI crawlers explicitly allowed |

Most tour sites do the first, half-do the second, and ignore the third. The third is where
this architecture is unusual — treat it as a first-class target, not a bonus.

## Two modes

### Mode A — audit an existing site

1. Run the checker against a running dev server:
   ```
   npm run dev            # in the site repo
   node ~/.claude/skills/tour-site-seo/scripts/seo-audit.mjs http://localhost:3000
   ```
   Check the dev server's actual port first — if another site in `artist-tours/` is
   already on 3000, Next picks 3001. Never kill a server you did not start.
   It crawls `sitemap.xml` and validates canonicals, title/description uniqueness and length,
   OG tags, `h1` count, image `alt`, every JSON-LD block (parse + required fields),
   `robots.txt`, and `llms.txt`. Exit code is non-zero on FAIL.
2. Work the failures, then read `references/audit-checklist.md` for the items a script
   cannot judge (copy quality, slug/intent match, doorway-page risk, disclosure).
3. Report findings as **FAIL / WARN / OK** with the file and line to change. Do not
   restate passing items at length.

### Mode B — build or extend

Follow `references/architecture.md` top to bottom. Order matters — later steps depend on
`lib/site.ts` and the city data table existing:

1. `lib/site.ts` — site constants (single source of truth)
2. `app/layout.tsx` — `metadataBase`, twitter card, verification
3. `data/` — tour dates fetch + hand-written city table
4. `app/page.tsx` — data-driven `generateMetadata` + four JSON-LD blocks
5. `app/[city]/page.tsx` — `generateStaticParams` + `dynamicParams = false` + three JSON-LD blocks
6. `app/sitemap.ts`, `app/robots.ts`, `app/llms.txt/route.ts`
7. Content pass with `references/content.md`
8. Audit with the script

## Non-negotiables

Violating any of these is a FAIL, not a nit.

1. **Every page sets its own `title`, `description`, and absolute `alternates.canonical`.**
   No page inherits a title from the layout. No relative canonical.
2. **`metadataBase: new URL(SITE_URL)` in the root layout.** Without it, OG image URLs
   resolve wrong in production and social previews break silently.
3. **Slug = the search term, not the venue's legal city.** The Chicago show is in Hoffman
   Estates; the page is `/chicago` with a `venueCity: "Hoffman Estates"` field used to match
   API rows. Same for `/washington` → Vienna VA, `/san-francisco` → Oakland, `/seattle` →
   Everett, `/dallas` → Grand Prairie, `/houston` → Sugar Land, `/austin` → Cedar Park.
   Nobody searches "Hoffman Estates". The body copy still names the real suburb — the URL
   matches intent, the content stays honest.
4. **`generateStaticParams()` + `export const dynamicParams = false`** on every dynamic
   route. A closed URL space means no soft-404s and no infinite crawlable junk.
5. **Per-city `metaTitle` and `metaDescription` are hand-written data fields**, never
   template strings. Twelve templated descriptions read as twelve near-duplicate pages.
6. **Two blocks of genuinely unique prose per city page** (`venueNotes`, `gettingThere`)
   naming real transit lines, capacities, and drive times. This is the only thing separating
   a city page from a doorway page.
7. **JSON-LD is inlined with `dangerouslySetInnerHTML` and `<` escaped**, never via
   `next/script` (which can defer past the crawler's parse):
   ```tsx
   function jsonLdScript(data: object) {
     return { __html: JSON.stringify(data).replace(/</g, "\\u003c") };
   }
   ```
8. **FAQ JSON-LD is built from the same array the visible accordion renders.** Schema that
   claims content the page does not show is a manual-action risk.
9. **`startDate` carries a UTC offset** (`2026-09-03T20:00:00-05:00`), not a bare date.
   Google drops event rich results without it.
10. **Upstream fetch failure degrades, never 500s.** Return `[]`/`null`, render fallback
    copy, fall back to the static site description. Wrap fetches in React `cache()` so
    `generateMetadata` and the page body share one request.
11. **Unofficial fan sites disclose in four places** — header badge, footer paragraph,
    `llms.txt`, and the `siteName` / `WebSite.name` string. Also state that nothing is sold
    on-site. This is what keeps the site citable instead of flagged.

## Reference files

Read the one you need; don't load all four.

- `references/architecture.md` — file-by-file implementation with working code
- `references/structured-data.md` — JSON-LD recipes, required fields, which page gets what
- `references/answer-engines.md` — `llms.txt`, AI-crawler robots rules, writing for citation
- `references/content.md` — title/description formulas, city copy, FAQ rules
- `references/audit-checklist.md` — the human-judgment checklist and known gaps backlog

## Known gaps in the reference site

Carry these as the improvement backlog for any site built from this pattern. They are the
highest-value remaining work, roughly in order:

1. **No per-page OG images.** Every page shares one remote banner. `app/[city]/opengraph-image.tsx`
   with `ImageResponse` (city name + date + venue on the tour art) would lift social CTR.
2. **No favicon / `icon.png` / `apple-icon.png`.** Removed in commit `fac3757` and never
   restored; the SERP favicon slot is empty.
3. **No `not-found.tsx`.** Unmatched routes get the framework default.
4. **No `VideoObject` JSON-LD** despite embedded YouTube on every page.
5. **Same hardcoded poster image on all 12 city pages**, with alt text claiming it is that
   city's poster. Use the event's own `bannerUrl`, which is already fetched.
6. **Dead `href="#"`** on the footer "Back to top" link.
7. **Mobile and desktop tour tables are both in the DOM** (`md:hidden` / `hidden md:block`),
   duplicating every date string. Acceptable at this size; do not scale the pattern.

## Next 16 specifics that trip people up

- **Streaming metadata**: async `generateMetadata` streams tags into `<body>` for
  JS-executing bots (Googlebot handles this). HTML-limited bots like `facebookexternalhit`
  still get blocking `<head>` tags. If you view-source and metadata looks "missing", check
  the rendered DOM before changing anything. Only reach for `htmlLimitedBots` in
  `next.config.ts` if a specific named scraper misbehaves.
- `themeColor`, `colorScheme`, and `viewport` moved out of `metadata` into
  `generateViewport` — they are deprecated in the `metadata` object.
- File-based metadata (`opengraph-image.tsx`, `icon.png`) **overrides** the `metadata`
  object. If an OG image will not change, look for a file convention shadowing it.
- `params` is a Promise in App Router — `const { city } = await params`.
references/answer-engines.md8.4 KB
# Answer engines (AEO / GEO)

Getting cited by ChatGPT, Perplexity, Claude, and Google AI Overviews is a different
optimization target from ranking. Ranking rewards relevance and authority; citation rewards
**extractability** — a model has to be able to lift a specific, verifiable, self-contained
claim off the page and attribute it.

The reference site does four things for this. All four are cheap.

---

## 1. Let the crawlers in, by name

`app/robots.ts`:

```ts
const AI_CRAWLERS = [
  "GPTBot",            // OpenAI training
  "OAI-SearchBot",     // ChatGPT search index
  "ChatGPT-User",      // live fetch during a chat
  "ClaudeBot",         // Anthropic index
  "Claude-User",       // live fetch during a chat
  "Claude-SearchBot",
  "PerplexityBot",     // Perplexity index
  "Perplexity-User",   // live fetch
  "Google-Extended",   // Gemini / AI Overviews grounding
  "Applebot-Extended", // Apple Intelligence
  "CCBot",             // Common Crawl — feeds many models
  "Amazonbot",
  "meta-externalagent",
];

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/" },
      ...AI_CRAWLERS.map((userAgent) => ({ userAgent, allow: "/" })),
    ],
    sitemap: `${SITE_URL}/sitemap.xml`,
  };
}
```

The per-agent rules are redundant against `*: allow` today. Keep them anyway: robots.txt is
most-specific-match, so an explicit `allow` for `GPTBot` survives someone later adding a
`Disallow` under `*`. It also documents intent for the next person who edits the file.

Note the split between **index bots** and **user-fetch bots** (`ChatGPT-User`,
`Perplexity-User`, `Claude-User`). Blocking the latter means a user who pastes your URL into
a chat gets "I can't access that page" — the worst possible outcome for a site whose value is
being the current answer.

---

## 2. `app/llms.txt/route.ts`

A plain-text Markdown digest of the whole site at `/llms.txt`. A model fetching one URL gets
the entire dataset without rendering twelve pages.

```ts
export const revalidate = 3600;

export async function GET() {
  const shows = await getTourDates();
  // …
  return new Response(body, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}
```

Structure, in order:

```markdown
# {Artist} Tour {Year} — {Tour Name} (Unofficial Fan Site)

> {One-sentence summary}. Data is refreshed hourly from official event listings. This is a
> fan-maintained site, not affiliated with {Artist} or her management; it sells nothing and
> links only to official ticket sellers.

## Quick answer

{Artist} is touring North America in 2026 on The Unstoppable Tour, with 12 confirmed shows
from Thu, Sep 3, 2026 (Boston) to Sun, Sep 27, 2026 (Los Angeles). Tickets are sold only
through the official sellers linked on this site.

## Tour schedule

- Thu, Sep 3, 2026 — Boston, USA — Agganis Arena (tickets on sale) — details: https://…/boston
- …

## City guides

Each city page has the venue address, directions, transit notes, and the official ticket link.

- [Boston](https://…/boston): {metaDescription}
- …

## About {Artist} (verified facts)

- Indian playback singer, born in Murshidabad district, West Bengal; won Sa Re Ga Ma Pa as a teenager.
- Film debut as a playback singer: Devdas (2002), for director Sanjay Leela Bhansali.
- Five National Film Awards for Best Female Playback Singer, for songs in Hindi, Bengali, Marathi, and Tamil films.
- In 2010, Ohio's governor declared June 26 "Shreya Ghoshal Day" after her concert in Columbus, Ohio.

## Frequently asked questions

- Q: When do tickets go on sale?
  A: {full answer}

## Pages

- [Homepage — full schedule, FAQ, and tour info](https://…)
- [Sitemap](https://…/sitemap.xml)
```

Why each part earns its place:

- **Blockquote summary** — the first thing a model reads. Carries the disclosure and the
  freshness claim in one sentence.
- **Quick answer** — a paragraph a model can quote verbatim to answer the head query.
- **Schedule with `— details: {url}`** — gives the model a per-city URL to cite. Without
  the URLs it summarizes; with them it links.
- **"(verified facts)" heading** — signals these are checkable claims, and keeps whoever
  edits the file honest about what belongs there.
- **FAQs inline** — the same array as the page and the JSON-LD. Three renderings, one source.

Build it from the live data, not a static string, and set `revalidate = 3600` to match the
feed. A stale `llms.txt` is worse than none.

Serve it as `text/plain; charset=utf-8`.

---

## 3. The QuickAnswer block

The first content section after the hero is a single paragraph that answers the primary
query completely:

```tsx
<section id="summary" aria-label="Tour summary">
  <h2>The short version</h2>
  <p>
    {Artist} is touring North America in 2026 on The Unstoppable Tour, with {shows.length}{" "}
    confirmed shows: {cityList}. The tour runs from{" "}
    <time dateTime={first.dateTime}>{formatTourDate(first.date)}</time> ({first.city}) to{" "}
    <time dateTime={last.dateTime}>{formatTourDate(last.date)}</time> ({last.city}).
    Tickets are sold only through official sellers — every date links to one in the table below.
  </p>
</section>
```

Rules:

- **One paragraph, complete sentences, no bullets.** Models extract prose more reliably than
  fragments, and a quoted sentence needs to read as a sentence.
- **Spell out entities.** "{Artist} is touring North America in 2026", not "The tour runs…".
  The extracted sentence has to make sense with zero surrounding context.
- **Numbers and names, from live data.** Show count, first and last city, both dates.
- **Cities joined with an Oxford comma** (`.replace(/, ([^,]*)$/, ", and $1")`) so the list
  reads naturally when quoted.
- **`<time dateTime>`** on both dates.
- Renders `null` when there are no shows rather than emitting an empty claim.

---

## 4. Write facts that are worth citing

A model cites the page that has the specific, checkable detail. The reference site's copy is
full of them, and that is not accidental:

| Vague | What the site says |
|---|---|
| "A large arena" | "Scotiabank Arena … home of the Maple Leafs and the Raptors. It is the biggest room on the tour." |
| "Easy to reach" | "BART's Coliseum station connects to Oakland Arena by a walkway, about 20 minutes from downtown San Francisco." |
| "An intimate venue" | "The arena holds about 7,000 people, one of the smallest rooms on this tour." |
| "She's won awards" | "Five National Film Awards for Best Female Playback Singer, for songs in Hindi, Bengali, Marathi, and Tamil films." |
| "Popular in the US" | "In 2010, Ohio's governor declared June 26 'Shreya Ghoshal Day' after her concert in Columbus, Ohio." |
| "Tickets aren't cheap" | "Arena shows on this tour typically start around $50–75 for upper-level seats." |

Every one of those is a sentence a model can lift and attribute. Generic praise is
unquotable — it appears on a thousand pages, so there is no reason to cite yours.

Named entities do double duty: transit lines (Green Line B, PATH, BART, Silver Line, Sounder
N Line), venue former names (Oracle Arena, Verizon Theatre), highways (I-90, US-183, I-880),
and capacities all connect the page to entities the model already knows.

---

## 5. Freshness, stated and real

- `revalidate: 3600` on every upstream fetch and on the `llms.txt` route.
- The homepage description literally computes the count and range, and ends "Updated daily."
- `llms.txt` says "Data is refreshed hourly from official event listings."
- `sitemap.ts` sets `lastModified: new Date()` and `changeFrequency: "daily"` on the home.

Only claim the cadence you actually deliver. If you drop the ISR interval, fix the copy in
the same commit.

---

## 6. Disclosure is an asset, not a liability

For an unofficial fan site, being clearly labeled is what makes it safe to cite. The
reference site discloses in four places:

1. Header badge: "Unofficial fan site"
2. Footer: "This is an unofficial fan-made website. We are not affiliated with {Artist} or
   her management. Tickets are sold only through official platforms."
3. `llms.txt` blockquote, first paragraph
4. `siteName` / `WebSite.name`: "Shreya Ghoshal Tour (Fan Site)"

Plus "Every ticket link above goes to the official seller for that show — nothing is sold on
this site" next to the table.

A model that can tell what your site is will describe it accurately. A model that cannot will
either skip it or misattribute it as official — and the latter is what gets a site
takedown-flagged.
references/architecture.md14.7 KB
# Architecture — file by file

Build in this order. Every snippet is from the working reference site
(`artist-tours/shreyaghoshaltour`), lightly generalized.

---

## 1. `lib/site.ts` — single source of truth

Everything that appears in more than one `<head>` lives here. Never inline a domain, title,
or banner dimension anywhere else.

```ts
export const SITE_URL = "https://shreyaghoshaltour.com";
export const SITE_TITLE =
  "Shreya Ghoshal Tour 2026 – Unstoppable Tour Dates & Tickets";
export const SITE_DESCRIPTION =
  "Every confirmed date on Shreya Ghoshal's Unstoppable Tour 2026 — cities, venues, and official ticket links for all shows across the US and Canada.";
export const BANNER_URL = "https://cdn-az.allevents.in/.../banner.jpg";
export const BANNER_WIDTH = 1200;   // OG images: 1200×630 or 1200×675
export const BANNER_HEIGHT = 675;
export const BANNER_ALT = "Shreya Ghoshal — The Unstoppable Tour 2026";
```

Two helpers also live here:

```ts
/**
 * Rewrites a CDN URL to our path-based /img proxy so the CDN host never
 * appears in the DOM. Non-matching URLs pass through as-is.
 */
export function proxyImg(url: string): string {
  const match = url.match(/^https:\/\/([a-z0-9-]+)\.allevents\.in(\/[^?]*)(\?.*)?$/i);
  if (!match) return url;
  return `/img/${match[1]}${match[2]}${match[3] ?? ""}`;
}

export function ticketLink(eventId: string): string {
  return `https://allevents.in/pages/link?event_id=${eventId}&ref=shreya-ghoshal-tour`;
}
```

`BANNER_WIDTH`/`HEIGHT` are exported specifically so the hero `<Image>` and the OG
`images[]` entry use the same numbers — that is what prevents CLS on the LCP element and a
cropped social preview.

---

## 2. `app/layout.tsx` — site-wide only

The root layout carries **only** what is genuinely global. It must not set a `title` or
`description`, or pages that forget to set their own will silently inherit and duplicate them.

```tsx
export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  twitter: { card: "summary_large_image" },
  verification: { google: "5-zcEYCwYGRzSGzt0aOOsXTtet7BXo-DhBzJv9BvdpM" },
};
```

Also here:

- `<html lang="en">` — required; the audit script checks it.
- Fonts via `next/font/google` with `variable`, so there is no render-blocking font request
  and no swap-induced layout shift.
- Analytics **after** the content: `GoogleAnalytics` from `@next/third-parties/google`, and
  Clarity via `<Script strategy="afterInteractive">`. Never `beforeInteractive` for
  analytics — it blocks the LCP.

---

## 3. `data/tourDates.ts` — the live feed

```ts
export const getTourDates = cache(async (): Promise<TourDate[]> => {
  const token = process.env.ALLEVENTS_API_TOKEN;
  if (!token) {
    console.warn("ALLEVENTS_API_TOKEN is not set; rendering without dates.");
    return [];                                   // degrade, never throw
  }
  try {
    const res = await fetch(API_URL, {
      method: "POST",
      headers: { "User-Agent": "ae-internal", TOKEN: token, "Content-Type": "application/json" },
      body: JSON.stringify({ performer_id: PERFORMER_ID, count: 30 }),
      next: { revalidate: 3600 },                // freshness signal
    });
    if (!res.ok) throw new Error(`API responded with ${res.status}`);
    const payload = await res.json();
    return dedupeShows(payload.data?.events ?? [])
      .sort((a, b) => a.start_time - b.start_time)
      .map(toTourDate);
  } catch (error) {
    console.error("Failed to load tour dates:", error);
    return [];                                   // crawler sees a page, not a 500
  }
});
```

Three things to copy exactly:

- **`cache()` from React** — `generateMetadata` and the page body both call `getTourDates()`.
  Without `cache()` that is two upstream requests per render.
- **`next: { revalidate: 3600 }`** — hourly ISR. Pair it with "Updated daily" in the
  description and `lastModified` in the sitemap.
- **Dedupe** — feeds commonly carry one show under two listings. Key on `date|city` and
  prefer the listing whose name matches the official tour title. Duplicate rows become
  duplicate `MusicEvent` entries, which reads as spam.

Timezone handling matters for schema. The feed encodes venue-local wall time as a UTC epoch:

```ts
export function localDateParts(startTime: number, timezone: string) {
  const local = new Date(startTime * 1000).toISOString();
  const offset = /^[+-]\d{2}:\d{2}$/.test(timezone) ? timezone : "";
  return { date: local.slice(0, 10), dateTime: local.slice(0, 19) + offset };
}
```

`dateTime` (with offset) goes into JSON-LD `startDate` and `<time dateTime>`; `date` is for
display formatting.

---

## 4. `data/cityEvents.ts` — the hand-written city table

This file is the SEO surface for every city query. It is data, not code, and every string in
it is written by a human.

```ts
export interface TourCity {
  slug: string;          // URL segment — the metro people search, e.g. "chicago"
  name: string;          // heading + link label, e.g. "Chicago"
  venueCity: string;     // as it appears in feed data, e.g. "Hoffman Estates"
  eventId: string;
  venueNotes: string;    // unique prose: what kind of room, capacity, why it matters
  gettingThere: string;  // unique prose: named transit lines, parking, drive time
  metaTitle: string;     // ~60 chars, hand-written
  metaDescription: string; // ~155 chars, hand-written, one fact unique to this city
}
```

The `slug` / `venueCity` split is the highest-leverage decision in the repo:

| slug | venueCity | Why |
|---|---|---|
| `chicago` | Hoffman Estates | 30 mi from downtown; nobody searches the suburb |
| `washington` | Vienna | Wolf Trap is in Virginia |
| `san-francisco` | Oakland | Bay Area intent |
| `seattle` | Everett | 25 mi north |
| `dallas` | Grand Prairie | Between Dallas and Fort Worth |
| `houston` | Sugar Land | 20 mi southwest |
| `austin` | Cedar Park | Northern suburb |
| `newark` | Newark | Slug is honest, but `metaTitle` says "New York/NJ" for the tri-state query |

Lookups:

```ts
export function cityBySlug(slug: string) {
  return tourCities.find((c) => c.slug === slug);
}
export function citySlugForVenueCity(venueCity: string) {
  return tourCities.find((c) => c.venueCity.toLowerCase() === venueCity.toLowerCase())?.slug;
}
```

`citySlugForVenueCity` is what lets the homepage table link a feed row (which knows only
"Hoffman Estates") to `/chicago`. It is also used by `llms.txt`.

---

## 5. `app/page.tsx` — the homepage

### Data-driven description

The description is computed from live data every revalidation, so the SERP snippet always
states the current show count and date range:

```tsx
export async function generateMetadata(): Promise<Metadata> {
  const shows = await getTourDates();
  let description = SITE_DESCRIPTION;               // fallback if the feed is down
  if (shows.length > 0) {
    const first = shows[0], last = shows[shows.length - 1];
    const sameMonth = first.date.slice(0, 7) === last.date.slice(0, 7);
    const range = sameMonth
      ? `${shortDate(first.date)}–${Number(last.date.slice(8, 10))}`
      : `${shortDate(first.date)} – ${shortDate(last.date)}`;
    description = `All ${shows.length} dates on Shreya Ghoshal's Unstoppable Tour 2026 — ${first.city} to ${last.city}, ${range}. Venues, city guides & official ticket links. Updated daily.`;
  }
  return {
    title: SITE_TITLE,
    description,
    alternates: { canonical: SITE_URL },
    openGraph: {
      title: SITE_TITLE, description, url: SITE_URL,
      siteName: "Shreya Ghoshal Tour (Fan Site)",
      type: "website", locale: "en_US",
      images: [{ url: proxyImg(BANNER_URL), width: BANNER_WIDTH, height: BANNER_HEIGHT, alt: BANNER_ALT }],
    },
  };
}
```

Note `SITE_DESCRIPTION` as the fallback — a feed outage degrades the snippet, it does not
empty it.

### Section order is deliberate

```
Header → Hero (h1 + banner) → Marquee → QuickAnswer → TourDates → About
       → AboutArtist → Watch → EssentialInfo → FAQ → Footer
```

`QuickAnswer` sits directly after the hero because it is the block answer engines and
featured snippets extract. See `references/answer-engines.md`.

`TourDates` renders `<table>` with an `sr-only` `<caption>` and `scope="col"` headers, plus
`<time dateTime>` on every row — machine-readable without JSON-LD even being parsed.

---

## 6. `app/[city]/page.tsx` — programmatic pages

```tsx
export const dynamicParams = false;                // closed URL space

export function generateStaticParams() {
  return tourCities.map((city) => ({ city: city.slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { city: slug } = await params;             // params is a Promise in App Router
  const city = cityBySlug(slug);
  if (!city) return {};
  const event = await getEventDetails(city.eventId);
  return {
    title: city.metaTitle,                         // hand-written, not templated
    description: city.metaDescription,
    alternates: { canonical: `${SITE_URL}/${city.slug}` },
    openGraph: {
      title: city.metaTitle, description: city.metaDescription,
      url: `${SITE_URL}/${city.slug}`,
      siteName: "Shreya Ghoshal Tour (Fan Site)",
      type: "website", locale: "en_US",
      ...(event && { images: [{ url: proxyImg(event.bannerUrl) }] }),
    },
  };
}
```

Page body requirements:

- Exactly one `<h1>`, containing artist + city.
- `<h2>` per section, phrased as the query: "The venue", "Getting there", "Quick answers".
- Venue block with full address and a map link.
- Graceful `EventUnavailable` component when the API returns null — the page still renders
  the `<h1>`, cross-links, and breadcrumb schema.
- Cross-links to every other city (`tourCities.filter(c => c.slug !== city.slug)`), anchor
  text = city name.
- Three JSON-LD blocks: `MusicEvent`, `FAQPage`, `BreadcrumbList`.

City FAQs are generated from event data, which is fine here **because the answers contain
per-city facts** (venue name, local showtime, street address, that city's transit note):

```tsx
function buildCityFaqs(city: TourCity, event: EventDetails) {
  return [
    { question: `When is ${ARTIST} performing in ${city.name}?`,
      answer: `${event.displayTime} local time at ${event.venue}. Doors typically open 60–90 minutes before showtime.` },
    { question: `Where is the ${city.name} concert held?`,
      answer: `At ${event.venue}, ${event.street}, ${event.city}, ${event.state}. ${city.gettingThere}` },
    { question: `How do I buy tickets for the ${city.name} show?`,
      answer: `Use the Get tickets button on this page — it opens the official seller for this date.` },
  ];
}
```

The line to hold: templated **questions** with fact-carrying **answers** are fine; templated
answers that say the same thing on all 12 pages are not.

---

## 7. `app/sitemap.ts`

```ts
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: SITE_URL, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
    ...tourCities.map((city) => ({
      url: `${SITE_URL}/${city.slug}`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.8,
    })),
  ];
}
```

Generated from the same `tourCities` array the routes are — a city can never exist in one
and not the other.

---

## 8. `app/robots.ts`

```ts
const AI_CRAWLERS = [
  "GPTBot", "OAI-SearchBot", "ChatGPT-User",
  "ClaudeBot", "Claude-User", "Claude-SearchBot",
  "PerplexityBot", "Perplexity-User",
  "Google-Extended", "Applebot-Extended",
  "CCBot", "Amazonbot", "meta-externalagent",
];

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/" },
      ...AI_CRAWLERS.map((userAgent) => ({ userAgent, allow: "/" })),
    ],
    sitemap: `${SITE_URL}/sitemap.xml`,
  };
}
```

Redundant with `*: allow` today — deliberately. It survives someone later adding a
`Disallow` under `*`, and it documents the intent: being crawled by answer engines is how
this site gets cited.

---

## 9. `app/llms.txt/route.ts`

See `references/answer-engines.md` for the full structure and rationale.

---

## 10. `app/img/[sub]/[...path]/route.ts` — first-party image proxy

Streams CDN images through the site's own domain, so image URLs are first-party and
long-cached, and the CDN host never appears in the DOM.

```ts
const SUB_RE = /^[a-z0-9-]{1,32}$/;

export async function GET(req, { params }) {
  const { sub, path } = await params;
  if (!SUB_RE.test(sub) || !path?.length) return new NextResponse("Bad request", { status: 400 });

  // Host is hardcoded — only the subdomain is caller-controlled and validated,
  // so this cannot be used as an open proxy.
  const upstream = `https://${sub}.allevents.in/${path.map(encodeURIComponent).join("/")}${req.nextUrl.search}`;

  const res = await fetch(upstream, { signal: controller.signal, next: { revalidate: 86400 } });
  if (!res.ok || !res.body) return new NextResponse("Upstream error", { status: 502 });

  return new NextResponse(res.body, {
    status: 200,
    headers: {
      "Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
      "Cache-Control": "public, max-age=86400, immutable",
    },
  });
}
```

**Security note when copying this**: the upstream host must stay hardcoded. If you let the
caller supply the host, it becomes an open proxy. Keep the subdomain regex and the 8s
`AbortController` timeout.

Register the proxy path in `next.config.ts` so `next/image` will optimize it:

```ts
images: {
  localPatterns: [{ pathname: "/img/**" }],
  remotePatterns: [{ protocol: "https", hostname: "*.allevents.in" }],
}
```

---

## 11. Outbound ticket links

`TicketButton` is a client component that renders a real `<button>` and opens the affiliate
redirect with `window.open(...)` on click:

```tsx
<button type="button" onClick={() => {
  sendGAEvent("event", "ticket_click", { event_id: eventId });
  window.open(ticketLink(eventId), "_blank", "noopener,noreferrer");
}}>
```

No `<a href>` is emitted, so the affiliate redirect never enters the crawlable link graph —
no PageRank flows to a tracking redirector and there is no un-attributed monetized link to
label. The honest signal is preserved where it belongs: JSON-LD `offers.url` still carries
the real ticket URL for crawlers.

If you ever switch these to real anchors, they need `rel="sponsored nofollow noopener"`.

---

## 12. Core Web Vitals

- **LCP**: hero via `next/image` with `priority`, explicit `width`/`height` from the shared
  constants, and a `sizes` string matching the container (`"(max-width: 1148px) 100vw, 1100px"`).
- **CLS**: every image has intrinsic dimensions or `fill` + a sized parent; fonts via
  `next/font` with `variable`.
- **INP/TBT**: `YouTubeEmbed` from `@next/third-parties/google` renders a lite facade — the
  player JS loads on click, not on page load. Spotify iframes use `loading="lazy"` and a
  descriptive `title`.
- Analytics and Clarity are `afterInteractive`.
references/audit-checklist.md7.1 KB
# Audit checklist

Run `scripts/seo-audit.mjs` first — it covers everything mechanical. This file is the part
that needs judgment. Report each item as **FAIL** (fix now), **WARN** (fix soon), or **OK**.

---

## A. Metadata — mostly scripted

| # | Check | Severity if failing |
|---|---|---|
| A1 | `metadataBase: new URL(SITE_URL)` in root layout | FAIL |
| A2 | Every route exports `metadata` or `generateMetadata` with `title` + `description` | FAIL |
| A3 | Root layout sets **no** `title`/`description` (pages would inherit) | FAIL |
| A4 | Absolute `alternates.canonical` on every page, matching its own URL | FAIL |
| A5 | All titles unique; 50–60 chars | WARN over 65 |
| A6 | All descriptions unique; 140–160 chars | WARN outside 70–165 |
| A7 | `openGraph` with `title`, `description`, `url`, `siteName`, `type`, `locale`, `images` | WARN |
| A8 | OG image ≥1200px wide with `width`/`height`/`alt` | WARN |
| A9 | `twitter.card: "summary_large_image"` in root layout | WARN |
| A10 | `<html lang>` set | FAIL |
| A11 | Google Search Console `verification` present | OK to omit pre-launch |

## B. Crawlability

| # | Check | Severity |
|---|---|---|
| B1 | `app/sitemap.ts` generated from the same data array the routes use | FAIL if hand-maintained |
| B2 | Every sitemap URL returns 200 | FAIL |
| B3 | Every route reachable by a crawlable `<a>`/`<Link>` from the homepage | FAIL |
| B4 | `app/robots.ts` with `sitemap:` pointing at the absolute URL | FAIL |
| B5 | AI crawlers explicitly allowed by name | WARN |
| B6 | `dynamicParams = false` + `generateStaticParams()` on every dynamic route | FAIL |
| B7 | No orphan pages, no crawl traps (unbounded params, session ids) | FAIL |
| B8 | `notFound()` called for unmatched data — not a 200 with empty content | FAIL |

## C. Structured data

| # | Check | Severity |
|---|---|---|
| C1 | Every JSON-LD block parses as JSON | FAIL |
| C2 | Inlined via `dangerouslySetInnerHTML`, **not** `next/script` | FAIL |
| C3 | `<` escaped as `<` | FAIL |
| C4 | `MusicEvent.startDate` includes a UTC offset | FAIL |
| C5 | `MusicEvent.image` is an absolute URL | WARN |
| C6 | `location.address` is a `PostalAddress` object, not a string | FAIL |
| C7 | `offers.availability` omitted (not guessed) when the on-sale date is unknown | WARN |
| C8 | `FAQPage` questions match the visible accordion exactly | FAIL |
| C9 | `Person`/`MusicGroup` has `sameAs` (Wikipedia + verified socials) | WARN |
| C10 | `BreadcrumbList` on every city page | WARN |
| C11 | No duplicate `MusicEvent` for the same date+city | FAIL |

## D. Content — judgment required

| # | Check | Severity |
|---|---|---|
| D1 | Exactly one `<h1>` per page | FAIL |
| D2 | Heading levels not skipped | WARN |
| D3 | **Slug matches search intent, not the venue's legal city** | FAIL |
| D4 | `metaTitle`/`metaDescription` hand-written per city, not template strings | FAIL |
| D5 | Two blocks of genuinely unique prose per city page | FAIL |
| D6 | Read all city `venueNotes` back to back — sentence *structure* varies, not just nouns | FAIL if not |
| D7 | FAQ answers stand alone, contain concrete numbers, no "see above" | WARN |
| D8 | QuickAnswer paragraph present, complete sentences, entities spelled out | WARN |
| D9 | No fabricated prices, capacities, or setlists | FAIL |
| D10 | `<time dateTime>` on every date | WARN |
| D11 | Every image has meaningful `alt`; every iframe has `title` | WARN |
| D12 | Anchor text is the destination's name, never "click here" | WARN |

## E. Freshness

| # | Check | Severity |
|---|---|---|
| E1 | `next: { revalidate: N }` on every upstream fetch | FAIL |
| E2 | Homepage description computed from live data (count + range) | WARN |
| E3 | Claimed update cadence matches the actual `revalidate` interval | FAIL if mismatched |
| E4 | `sitemap.lastModified` set | WARN |
| E5 | Fetches wrapped in React `cache()` so metadata + body share one request | WARN |

## F. Resilience

| # | Check | Severity |
|---|---|---|
| F1 | API failure returns `[]`/`null` and logs — never throws | FAIL |
| F2 | Page renders usable content with no data (fallback component) | FAIL |
| F3 | Metadata falls back to static site constants | FAIL |
| F4 | Missing env var warns and degrades rather than crashing the build | WARN |
| F5 | Event-dependent JSON-LD guarded by `{event && …}` | WARN |

## G. Performance

| # | Check | Severity |
|---|---|---|
| G1 | LCP image uses `next/image` with `priority` + explicit dimensions + `sizes` | FAIL |
| G2 | Fonts via `next/font` with `variable` | WARN |
| G3 | YouTube via `@next/third-parties` `YouTubeEmbed` (facade), not a raw iframe | WARN |
| G4 | Third-party iframes `loading="lazy"` | WARN |
| G5 | Analytics `afterInteractive`, never `beforeInteractive` | FAIL |
| G6 | Proxied images set long `Cache-Control` and register in `images.localPatterns` | WARN |
| G7 | No large duplicated DOM (mobile + desktop copies of the same list) | WARN |

## H. Trust / E-E-A-T (unofficial fan sites)

| # | Check | Severity |
|---|---|---|
| H1 | "Unofficial fan site" in the header | FAIL |
| H2 | Full disclaimer in the footer | FAIL |
| H3 | Disclosure in `llms.txt` | WARN |
| H4 | `siteName` / `WebSite.name` carries "(Fan Site)" | WARN |
| H5 | "Nothing is sold on this site" near ticket links | WARN |
| H6 | Ticket links go only to official sellers | FAIL |
| H7 | Outbound affiliate links are either non-crawlable (JS button) or `rel="sponsored nofollow"` | FAIL |
| H8 | No official logos, no claim of `Organization` identity on the artist's behalf | FAIL |

## I. Answer engines

| # | Check | Severity |
|---|---|---|
| I1 | `/llms.txt` returns 200 as `text/plain` | WARN |
| I2 | Built from live data with matching `revalidate` | WARN |
| I3 | Contains: summary blockquote, quick answer, full schedule with per-city URLs, city index, verified facts, FAQs | WARN |
| I4 | User-fetch bots (`ChatGPT-User`, `Perplexity-User`, `Claude-User`) allowed | WARN |
| I5 | Facts are specific and checkable — named transit lines, capacities, dated events | WARN |

---

## Known gaps in the reference site

Not yet done in `shreyaghoshaltour`. Treat as the shared backlog:

1. **No per-page OG images.** All pages share one remote banner. Add
   `app/[city]/opengraph-image.tsx` with `ImageResponse`.
2. **No favicon / `icon.png` / `apple-icon.png`** (removed in `fac3757`). The SERP favicon
   slot is empty.
3. **No `not-found.tsx`.**
4. **No `VideoObject` JSON-LD** despite YouTube embeds on every page.
5. **The same hardcoded poster image on all 12 city pages**, with alt text claiming it is
   that city's poster. `event.bannerUrl` is already fetched — use it.
6. **Dead `href="#"`** on the footer "Back to top" link.
7. **Mobile + desktop tour lists both in the DOM**, duplicating every date string.
8. **No `manifest.ts`**, no `generateViewport` themeColor.

## Reporting format

```
FAIL  D5  app/[city]/page.tsx — Austin and Seattle venueNotes are the same sentence
          skeleton with nouns swapped. Rewrite both.
WARN  A6  data/cityEvents.ts:87 — Chicago metaDescription is 168 chars; will truncate.
OK    C1–C11  All 4 homepage and 3 city JSON-LD blocks valid.
```

Group by severity, lead with FAILs, give file and line. Do not pad the report with passing
items.
references/content.md8.5 KB
# Content rules

Copy is where these sites are won or lost. The technical layer is a weekend; twelve genuinely
distinct city pages are the moat.

---

## Titles

**Homepage** — `{Artist} Tour {Year} – {Tour Name} Dates & Tickets`

> Shreya Ghoshal Tour 2026 – Unstoppable Tour Dates & Tickets  *(56 chars)*

**City page** — `{Artist} {Metro} {Year}: {Venue} Tickets` (+ `& Info` if room)

> Shreya Ghoshal Boston 2026: Agganis Arena Tickets & Info  *(56)*
> Shreya Ghoshal Bay Area 2026: Oakland Arena Tickets & Info  *(57)*
> Shreya Ghoshal New York/NJ 2026: Prudential Center Tickets  *(57)*

Rules:

- **50–60 characters.** Over ~60 truncates in SERPs. The audit script warns above 65.
- **Artist name first** — it is the highest-volume term and survives truncation.
- **Metro, not the venue's town.** "Chicago", not "Hoffman Estates". Note the Newark page
  uses "New York/NJ" to catch tri-state intent while the slug stays `/newark`.
- **Year in every title.** These queries are overwhelmingly year-qualified.
- **Venue name earns its slot** — it is a distinct query ("agganis arena shreya ghoshal") and
  it makes each of the twelve titles visibly different.
- No pipe-separated brand suffix. It wastes 15 characters on every page.
- Sentence case, consistent across the site (the reference site normalized this in commit
  `ed3878c`).

---

## Meta descriptions

**~150–160 characters. Hand-written per page. One fact unique to that page.**

The rule that matters: if you could swap two cities' descriptions and nobody would notice,
you have twelve duplicate pages wearing different names.

Look at how each of these leads with something only true of that city:

> **Washington DC** — "Shreya Ghoshal live under the open sky at Wolf Trap's Filene Center
> near Washington DC — the tour's only outdoor show. Lawn seating, directions, tickets."

> **Columbus** — "Shreya Ghoshal returns to Columbus — the city that declared 'Shreya Ghoshal
> Day' — at Nationwide Arena in 2026. Date, venue details, and official tickets."

> **San Francisco** — "Shreya Ghoshal plays Oakland Arena, the Bay Area's biggest room — BART
> goes straight to the door. Date, transit directions, and official ticket links."

> **Houston** — "Shreya Ghoshal at Smart Financial Centre in Sugar Land — a 6,400-seat
> theater where every Houston fan sits close. Show date, directions, official tickets."

Structure that works: **`{hook unique to this city} — {what's on the page}`**. The hook earns
the click; the tail sets expectations so the click doesn't bounce.

**Homepage descriptions are computed from live data:**

> All 12 dates on Shreya Ghoshal's Unstoppable Tour 2026 — Boston to Los Angeles, Sep 3 – 27.
> Venues, city guides & official ticket links. Updated daily.

A snippet stating the live count and range beats a static one, and "Updated daily" is a
click-through argument that costs nothing — provided the ISR interval actually delivers it.
Always keep a static fallback for when the feed is down.

---

## City page prose — the doorway-page line

Every city page needs **two hand-written blocks that could not have been generated from a
template**.

### `venueNotes` — what kind of room, and why it matters to a ticket buyer

> "The Boston show is at Agganis Arena, on Boston University's campus on Commonwealth Avenue.
> The arena holds about 7,000 people, one of the smallest rooms on this tour. Every seat is
> close to the stage."

> "The Los Angeles concert is at the Shrine Auditorium, a landmark 1926 theater near USC and
> Exposition Park that hosted the Oscars and the Grammys for years. It seats about 6,300 — a
> historic theater, not an arena."

Include: capacity, room type (arena / theater / amphitheater), a comparison to other stops on
the tour, former venue names, what the room means for the experience.

### `gettingThere` — named transit, real times, honest advice

> "Take the MBTA Green Line B — it runs along Commonwealth Avenue and stops a short walk from
> Agganis Arena. Parking near the arena is limited to a few campus garages. From central
> Boston, the T is the easier option."

> "Plan to drive. NOW Arena is just off I-90, and public transit does not reach it well. The
> arena has a large parking lot on site. Expect slow traffic leaving the lot right after the
> show ends."

Include: named lines and stations, drive time and distance from the city center, parking
reality, and a recommendation. **Say when transit is bad** — "plan to drive", "there is no
practical transit route". Honest negatives are the strongest signal that a human wrote this,
and they are what a reader actually needed.

### The test

Read all twelve `venueNotes` back to back. If the differences are only proper nouns swapped
into a shared sentence skeleton, rewrite them. Sentence *structure* has to vary, not just the
nouns.

---

## FAQ answers

Each answer must **stand alone** — a model or a featured snippet will show it with nothing
around it.

Good:

> **How long is the show?** — "Around two and a half to three hours, usually with no
> intermission. If your city has an opening act, plan for a longer evening — the listing will
> say so."

> **What time should I arrive?** — "Aim for 45–60 minutes before showtime. That covers the
> security queue and finding your seat. Most venues let latecomers in only between songs."

Rules:

- 2–4 sentences. One is thin; five buries the answer.
- **Concrete numbers**: `$50–75`, `45–60 minutes`, `2.5–3 hours`, `60–90 minutes`.
- No "see above", "as mentioned", or "click here" — there is no above when it is extracted.
- Answer the question in the **first sentence**; qualify after.
- Questions phrased the way people type them: "How much do tickets cost?", not "Pricing".
- **Hedge honestly.** "Prices vary by city and seating tier… The official listing for each
  city has exact prices" is better than a number you cannot stand behind. Fabricated
  specifics are the one thing worse than vagueness.
- Homepage FAQs cover the tour; city FAQs cover that city. Never repeat the homepage set on
  city pages.

---

## Headings

- **Exactly one `<h1>` per page**, containing artist + (city, on city pages).
- `<h2>` phrased as the question a reader has: "The venue", "Getting there", "Know before you
  go", "Quick answers", "The songs you'll hear". Not "Section 3" or "Details".
- Do not skip levels. `<h3>` inside a section, not a styled `<div>` — the outline is what
  gets parsed.
- Decorative section numbers ("01", "02") belong in a `<span>` inside the heading, never as
  the heading text.

---

## Semantic HTML worth being strict about

| Element | Where | Why |
|---|---|---|
| `<time dateTime={iso}>` | every date, everywhere | Machine-readable independent of JSON-LD |
| `<table>` + `<caption class="sr-only">` + `scope="col"` | tour dates | Parsed as a real data table |
| `<details>/<summary>` | FAQ accordions | Answer text is in the DOM with no JS |
| `<figure>/<figcaption>` | images, video embeds | Caption is bound to the media |
| `<dl>/<dt>/<dd>` | stat blocks | Key–value pairs, not styled divs |
| `alt` on every image | everywhere | `alt` describing content, not "image" |
| `title` on every `<iframe>` | Spotify, YouTube | Accessibility + context |
| `aria-hidden="true"` | decorative glyphs (`+`, `✦`, big background numerals) | Keeps junk out of the text layer |

---

## Internal linking

The reference site runs a **full mesh**: home links to all 12 city pages (twice — city names
in the table and the chip row), and every city page links to the other 11 plus a breadcrumb
home.

- **Anchor text is the city name.** Never "click here", "read more", or "this page".
- The tour table links each row's city through `citySlugForVenueCity()`, so a feed row that
  only knows "Hoffman Estates" still links to `/chicago`.
- Header anchors (`/#tour-dates`, `/#tickets`, `/#faq`) use `<Link>` and the targets carry
  `scroll-mt-20` so the sticky header does not cover them.
- Cities with no page render as plain text, not a broken link — `if (!slug) return <>{label}</>`.

---

## Words to cut

- "Don't miss out", "unforgettable night", "electrifying performance", "iconic voice" —
  fills space, cites nothing, appears on every competing page.
- "Best", "top", "ultimate" without a stated basis.
- Keyword stuffing the artist name into every sentence. It reads as spam to readers and does
  nothing for rankings. The reference site names the artist roughly once per paragraph, where
  it is naturally the subject.
- Invented prices, capacities, or setlists. One fabricated fact and the page is no longer
  citable — which is the whole asset.
references/structured-data.md7.4 KB
# Structured data (JSON-LD)

## Which page gets what

| Page | Schema types | Purpose |
|---|---|---|
| Homepage | `MusicEvent[]` (one per show), `FAQPage`, `Person`, `WebSite` | Event rich results for the whole tour, entity identity, sitelinks |
| City page | `MusicEvent` (single, full address), `FAQPage`, `BreadcrumbList` | Per-city event rich result + breadcrumb trail in SERP |

Four separate `<script type="application/ld+json">` blocks, not one `@graph`. Simpler to
build, simpler to debug in the Rich Results Test, and a malformed block cannot take the
others down with it.

## The embed helper — use this exactly

```tsx
function jsonLdScript(data: object) {
  return { __html: JSON.stringify(data).replace(/</g, "\\u003c") };
}

<script type="application/ld+json" dangerouslySetInnerHTML={jsonLdScript(faqJsonLd)} />
```

- The `<` → `<` escape prevents a `</script>` sequence inside any data field from
  breaking out of the block. Data comes from a third-party API — this is not optional.
- **Never use `next/script` for JSON-LD.** Its loading strategies can defer the tag past the
  point a crawler snapshots the page.

## MusicEvent

Required for Google event rich results: `name`, `startDate`, `location`. Everything else
below is what actually earns the enhanced treatment.

```ts
const AVAILABILITY: Partial<Record<TourDate["status"], string>> = {
  "on-sale": "https://schema.org/InStock",
  "sold-out": "https://schema.org/SoldOut",
};

{
  "@context": "https://schema.org",
  "@type": "MusicEvent",
  name: `${ARTIST} Live in ${show.city}`,
  description: `${ARTIST} performs live at ${show.venue}, ${show.city} on ${TOUR_NAME}.`,
  image: `${SITE_URL}${proxyImg(BANNER_URL)}`,      // absolute URL, always
  startDate: show.dateTime,                          // "2026-09-03T20:00:00-05:00"
  eventStatus: "https://schema.org/EventScheduled",
  eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
  performer: { "@type": "Person", name: ARTIST },
  location: {
    "@type": "Place",
    name: show.venue,
    address: {
      "@type": "PostalAddress",
      streetAddress: event.street,        // city pages only — richer data available
      addressLocality: show.city,
      addressRegion: event.state,
      addressCountry: show.country,
    },
  },
  offers: {
    "@type": "Offer",
    url: show.ticketUrl,
    ...(AVAILABILITY[show.status] && { availability: AVAILABILITY[show.status] }),
  },
}
```

Rules that are easy to get wrong:

1. **`startDate` must carry a UTC offset.** `"2026-09-03"` or a bare `"...T20:00:00"` gets
   the rich result dropped. Derive it from the feed's timezone field.
2. **`image` must be absolute.** `SITE_URL + proxyImg(url)` — a root-relative path is
   silently ignored here even though `metadataBase` fixes it for OG tags.
3. **Omit `availability` rather than guess.** The `AVAILABILITY` map deliberately has no
   entry for `"announced"`, and the spread omits the key entirely. Claiming `InStock` for a
   show with no on-sale date is a mismatch against the page, which says "On sale soon".
4. **`offers.url` points at the real ticket seller**, even though the visible button is a JS
   `window.open`. This is the honest machine-readable signal.
5. **One entry per show.** Dedupe the feed first (see `architecture.md` §3) — duplicate
   `MusicEvent` objects for the same date/city read as spam.
6. **`addressCountry` short codes.** Normalize `"United States"` → `"USA"` consistently
   across schema, display, and `llms.txt`.

Homepage emits the array directly:

```tsx
<script type="application/ld+json"
  dangerouslySetInnerHTML={jsonLdScript(buildMusicEventsJsonLd(shows))} />
```

An array of objects at the top level is valid JSON-LD and is how you express "this page
lists N events".

## FAQPage

```ts
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: faqs.map((faq) => ({
    "@type": "Question",
    name: faq.question,
    acceptedAnswer: { "@type": "Answer", text: faq.answer },
  })),
}
```

- **Built from the same array the accordion renders.** In the reference site, `data/faq.ts`
  carries a comment saying so — edit in one place, both update. Schema describing answers the
  page does not display is a manual-action risk.
- The visible accordion uses `<details>/<summary>`, so the answer text is in the DOM
  regardless of JS. Do not build FAQ accordions that inject answers on click.
- Answers must be complete sentences that stand alone. See `references/content.md`.
- City pages get their own `FAQPage` with city-specific questions — do not repeat the
  homepage FAQ on every city page.

## Person (the performer)

```ts
{
  "@context": "https://schema.org",
  "@type": "Person",
  name: "Shreya Ghoshal",
  description: "Indian playback singer with five National Film Awards, touring the US and Canada in 2026 on The Unstoppable Tour.",
  url: SITE_URL,
  sameAs: [
    "https://en.wikipedia.org/wiki/Shreya_Ghoshal",
    "https://www.instagram.com/shreyaghoshal/",
    "https://x.com/shreyaghoshal",
  ],
}
```

`sameAs` is entity disambiguation — it tells Google and LLMs which real-world person this
page is about. Wikipedia first, then verified official social profiles. Include only
profiles you can verify.

For an unofficial fan site: describe the artist, but do **not** claim `Organization`,
`author`, or `publisher` identity on their behalf, and do not use official logos.

## WebSite

```ts
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  name: "Shreya Ghoshal Tour (Fan Site)",
  alternateName: [
    "Shreya Ghoshal Unstoppable Tour 2026",
    "The Unstoppable Tour",
    "shreyaghoshaltour.com",
  ],
  url: SITE_URL,
  description: SITE_DESCRIPTION,
}
```

`alternateName` should enumerate the ways people actually refer to the site and tour —
official tour name, tour + year, and the bare domain. `name` carries the "(Fan Site)"
disclosure so the distinction travels with the structured data.

## BreadcrumbList (city pages)

```ts
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  itemListElement: [
    { "@type": "ListItem", position: 1, name: "Shreya Ghoshal Tour 2026", item: SITE_URL },
    { "@type": "ListItem", position: 2, name: city.name, item: `${SITE_URL}/${city.slug}` },
  ],
}
```

Two levels is correct for a flat `/[city]` structure. `name` on position 1 should match the
homepage's topic, not the literal string "Home".

## Emit order and conditionals

City pages guard the event-dependent blocks and always emit the breadcrumb:

```tsx
{event && (
  <>
    <script … dangerouslySetInnerHTML={jsonLdScript(buildEventJsonLd(event))} />
    <script … dangerouslySetInnerHTML={jsonLdScript(buildFaqJsonLd(faqs))} />
  </>
)}
<script … dangerouslySetInnerHTML={jsonLdScript(buildBreadcrumbJsonLd(city))} />
```

An API outage costs the event rich result, not a broken page or invalid schema.

## Not yet implemented — worth adding

- **`VideoObject`** for the embedded YouTube performances (`name`, `description`,
  `thumbnailUrl`, `uploadDate`, `embedUrl`). Every page has embeds and none are described.
- **`MusicGroup`** instead of `Person` when the act is a band (btstour).
- **`Place`/`aggregateRating`** — skip; do not invent ratings.

## Validation

1. `node <skill>/scripts/seo-audit.mjs http://localhost:3000` — parses every block and
   checks required fields.
2. Google Rich Results Test on the deployed URL — the only thing that reports actual
   eligibility.
3. Search Console → Enhancements → Events, after deploy.
scripts/seo-audit.mjs17.6 KB
#!/usr/bin/env node
/**
 * SEO audit for Next.js tour/event sites.
 *
 *   node seo-audit.mjs [baseUrl] [--json]
 *
 * Defaults to http://localhost:3000. Crawls /sitemap.xml (falling back to the
 * homepage plus any same-origin links it finds) and checks metadata, canonicals,
 * OpenGraph, headings, images, and every JSON-LD block, plus /robots.txt and
 * /llms.txt. Exits 1 if anything FAILs.
 *
 * No dependencies. Node 18+.
 */

const args = process.argv.slice(2);
const asJson = args.includes("--json");
const base = (args.find((a) => !a.startsWith("--")) ?? "http://localhost:3000").replace(/\/$/, "");

const findings = [];
const add = (level, code, where, message) => findings.push({ level, code, where, message });
const FAIL = (...a) => add("FAIL", ...a);
const WARN = (...a) => add("WARN", ...a);
const INFO = (...a) => add("INFO", ...a);

// ---------------------------------------------------------------- helpers

async function get(url) {
  try {
    const res = await fetch(url, {
      redirect: "follow",
      signal: AbortSignal.timeout(20000),
      headers: { "User-Agent": "tour-site-seo-audit/1.0" },
    });
    return { ok: res.ok, status: res.status, type: res.headers.get("content-type") ?? "", body: await res.text() };
  } catch (err) {
    return { ok: false, status: 0, type: "", body: "", error: String(err.message ?? err) };
  }
}

function attrs(tag) {
  const out = {};
  const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
  let m;
  while ((m = re.exec(tag))) out[m[1].toLowerCase()] = m[3] ?? m[4] ?? m[5] ?? "";
  return out;
}

const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'", "#x27": "'" };
function decode(s = "") {
  return s
    .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
    .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
    .replace(/&([a-z0-9#]+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m)
    .trim();
}

function tagsOf(html, name) {
  return html.match(new RegExp(`<${name}\\b[^>]*>`, "gi")) ?? [];
}

function metaContent(html, key, attr = "name") {
  for (const tag of tagsOf(html, "meta")) {
    const a = attrs(tag);
    if ((a[attr] ?? "").toLowerCase() === key.toLowerCase()) return decode(a.content ?? "");
  }
  return null;
}

function extract(html) {
  const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
  const canonicalTag = (html.match(/<link\b[^>]*>/gi) ?? []).find(
    (t) => (attrs(t).rel ?? "").toLowerCase() === "canonical",
  );
  const jsonLd = [];
  const ldRe = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
  let m;
  while ((m = ldRe.exec(html))) jsonLd.push(m[1]);

  return {
    title: titleMatch ? decode(titleMatch[1]) : null,
    description: metaContent(html, "description"),
    canonical: canonicalTag ? decode(attrs(canonicalTag).href ?? "") : null,
    lang: (html.match(/<html[^>]*\slang=["']([^"']+)["']/i) ?? [])[1] ?? null,
    robotsMeta: metaContent(html, "robots"),
    og: {
      title: metaContent(html, "og:title", "property"),
      description: metaContent(html, "og:description", "property"),
      url: metaContent(html, "og:url", "property"),
      image: metaContent(html, "og:image", "property"),
      siteName: metaContent(html, "og:site_name", "property"),
      type: metaContent(html, "og:type", "property"),
    },
    twitterCard: metaContent(html, "twitter:card") ?? metaContent(html, "twitter:card", "property"),
    h1Count: (html.match(/<h1[\s>]/gi) ?? []).length,
    imgs: tagsOf(html, "img").map(attrs),
    iframes: tagsOf(html, "iframe").map(attrs),
    times: (html.match(/<time\b[^>]*>/gi) ?? []).length,
    jsonLd,
    // Next.js renders JSON-LD via next/script as a <script id=...> with a src or
    // a strategy attribute; a plain inline ld+json block has neither.
    scriptDeferredLd: /<script[^>]+type=["']application\/ld\+json["'][^>]*\b(src|async|defer)\b/i.test(html),
  };
}

const isAbsolute = (u) => /^https?:\/\//i.test(u ?? "");
const pathOf = (u) => {
  try {
    return new URL(u, base).pathname.replace(/\/$/, "") || "/";
  } catch {
    return null;
  }
};

// ---------------------------------------------------------------- JSON-LD

function flatten(node, out = []) {
  if (Array.isArray(node)) node.forEach((n) => flatten(n, out));
  else if (node && typeof node === "object") {
    out.push(node);
    if (node["@graph"]) flatten(node["@graph"], out);
  }
  return out;
}

function checkJsonLd(page, raw) {
  const where = page.path;
  const nodes = [];

  raw.forEach((block, i) => {
    let parsed;
    try {
      parsed = JSON.parse(block);
    } catch (err) {
      FAIL("C1", where, `JSON-LD block #${i + 1} does not parse: ${err.message}`);
      return;
    }
    if (/<\/script|<script/i.test(block)) {
      FAIL("C3", where, `JSON-LD block #${i + 1} contains a literal "<script" — escape < as \\u003c`);
    }
    nodes.push(...flatten(parsed));
  });

  const byType = (t) => nodes.filter((n) => n["@type"] === t);
  const events = byType("MusicEvent").concat(byType("Event"));

  for (const ev of events) {
    const label = ev.name ?? "(unnamed event)";
    if (!ev.name) FAIL("C4", where, "MusicEvent is missing name");
    if (!ev.startDate) {
      FAIL("C4", where, `MusicEvent "${label}" is missing startDate`);
    } else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)$/.test(ev.startDate)) {
      FAIL("C4", where, `MusicEvent "${label}" startDate "${ev.startDate}" has no UTC offset — rich result will be dropped`);
    }
    const addr = ev.location?.address;
    if (!ev.location) FAIL("C6", where, `MusicEvent "${label}" is missing location`);
    else if (typeof addr !== "object" || addr?.["@type"] !== "PostalAddress") {
      FAIL("C6", where, `MusicEvent "${label}" location.address must be a PostalAddress object`);
    } else if (!addr.addressLocality || !addr.addressCountry) {
      WARN("C6", where, `MusicEvent "${label}" PostalAddress is missing addressLocality or addressCountry`);
    }
    if (!ev.image) WARN("C5", where, `MusicEvent "${label}" has no image`);
    else if (!isAbsolute(Array.isArray(ev.image) ? ev.image[0] : ev.image)) {
      WARN("C5", where, `MusicEvent "${label}" image must be an absolute URL`);
    }
    if (!ev.performer) WARN("C4", where, `MusicEvent "${label}" has no performer`);
    if (!ev.eventStatus) WARN("C4", where, `MusicEvent "${label}" has no eventStatus`);
    if (!ev.eventAttendanceMode) WARN("C4", where, `MusicEvent "${label}" has no eventAttendanceMode`);
    const offers = Array.isArray(ev.offers) ? ev.offers[0] : ev.offers;
    if (!offers?.url) WARN("C4", where, `MusicEvent "${label}" has no offers.url`);
  }

  const seen = new Map();
  for (const ev of events) {
    const key = `${ev.startDate}|${ev.location?.address?.addressLocality ?? ev.location?.name ?? ""}`;
    if (seen.has(key)) FAIL("C11", where, `Duplicate MusicEvent for ${key} — dedupe the feed`);
    seen.set(key, true);
  }

  for (const faq of byType("FAQPage")) {
    const qs = faq.mainEntity ?? [];
    if (!qs.length) FAIL("C8", where, "FAQPage has an empty mainEntity");
    for (const q of qs) {
      if (!q.name) FAIL("C8", where, "FAQPage Question is missing name");
      if (!q.acceptedAnswer?.text) FAIL("C8", where, `FAQPage answer missing for "${q.name}"`);
    }
  }

  for (const bc of byType("BreadcrumbList")) {
    const items = bc.itemListElement ?? [];
    if (items.length < 2) WARN("C10", where, "BreadcrumbList has fewer than 2 items");
    items.forEach((it, i) => {
      if (it.position !== i + 1) WARN("C10", where, `BreadcrumbList position ${it.position} out of order`);
      if (!it.item) WARN("C10", where, `BreadcrumbList item ${i + 1} has no item URL`);
    });
  }

  for (const p of byType("Person").concat(byType("MusicGroup"))) {
    if (!Array.isArray(p.sameAs) || !p.sameAs.length) {
      WARN("C9", where, `${p["@type"]} "${p.name}" has no sameAs — add Wikipedia + verified socials`);
    }
  }

  page.ldTypes = [...new Set(nodes.map((n) => n["@type"]).filter(Boolean))];
  page.eventCount = events.length;

  // A detail page (has a breadcrumb) with no event schema usually means the
  // upstream event fetch returned null and the guarded blocks were skipped.
  if (where !== "/" && byType("BreadcrumbList").length && !events.length) {
    WARN("F5", where, "Detail page has BreadcrumbList but no MusicEvent — the upstream event fetch is probably failing");
  }
}

// ---------------------------------------------------------------- per page

function checkPage(page) {
  const { path, data } = page;

  if (!data.title) FAIL("A2", path, "No <title>");
  else if (data.title.length > 65) WARN("A5", path, `Title is ${data.title.length} chars — will truncate: "${data.title}"`);
  else if (data.title.length < 20) WARN("A5", path, `Title is only ${data.title.length} chars: "${data.title}"`);

  if (!data.description) FAIL("A2", path, "No meta description");
  else if (data.description.length > 165) WARN("A6", path, `Description is ${data.description.length} chars — will truncate`);
  else if (data.description.length < 70) WARN("A6", path, `Description is only ${data.description.length} chars — thin`);

  if (!data.canonical) FAIL("A4", path, "No canonical link");
  else if (!isAbsolute(data.canonical)) FAIL("A4", path, `Canonical "${data.canonical}" is not absolute`);
  else if (pathOf(data.canonical) !== path) {
    FAIL("A4", path, `Canonical points to ${pathOf(data.canonical)} but this page is ${path}`);
  }

  if (!data.lang) FAIL("A10", path, "<html> has no lang attribute");

  if (data.robotsMeta && /noindex/i.test(data.robotsMeta)) {
    FAIL("B7", path, `meta robots is "${data.robotsMeta}" — page is deindexed`);
  }

  for (const [key, label] of [["title", "og:title"], ["description", "og:description"], ["url", "og:url"], ["image", "og:image"], ["siteName", "og:site_name"], ["type", "og:type"]]) {
    if (!data.og[key]) WARN("A7", path, `Missing ${label}`);
  }

  if (data.h1Count === 0) FAIL("D1", path, "No <h1>");
  else if (data.h1Count > 1) FAIL("D1", path, `${data.h1Count} <h1> elements — there must be exactly one`);

  const badAlt = data.imgs.filter((a) => a.alt === undefined);
  if (badAlt.length) WARN("D11", path, `${badAlt.length} <img> without an alt attribute`);
  const genericAlt = data.imgs.filter((a) => /^(image|photo|picture|img|banner)$/i.test((a.alt ?? "").trim()));
  if (genericAlt.length) WARN("D11", path, `${genericAlt.length} <img> with generic alt text`);

  const untitledFrames = data.iframes.filter((a) => !a.title);
  if (untitledFrames.length) WARN("D11", path, `${untitledFrames.length} <iframe> without a title attribute`);
  const eagerFrames = data.iframes.filter((a) => a.loading !== "lazy");
  if (eagerFrames.length) WARN("G4", path, `${eagerFrames.length} <iframe> not loading="lazy"`);

  if (!data.jsonLd.length) FAIL("C1", path, "No JSON-LD blocks");
  if (data.scriptDeferredLd) FAIL("C2", path, "JSON-LD script has src/async/defer — inline it with dangerouslySetInnerHTML");

  checkJsonLd(page, data.jsonLd);
}

// ---------------------------------------------------------------- site level

async function checkRobots() {
  const res = await get(`${base}/robots.txt`);
  if (!res.ok) return FAIL("B4", "/robots.txt", `Returned ${res.status || res.error}`);
  if (!/^\s*sitemap:\s*https?:\/\//im.test(res.body)) {
    FAIL("B4", "/robots.txt", "No absolute Sitemap: directive");
  }
  if (/^\s*disallow:\s*\/\s*$/im.test(res.body)) {
    FAIL("B4", "/robots.txt", "Contains 'Disallow: /' — the whole site is blocked");
  }
  const ai = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended", "ChatGPT-User", "Perplexity-User", "Claude-User", "OAI-SearchBot", "CCBot", "Applebot-Extended"];
  const missing = ai.filter((bot) => !new RegExp(`user-agent:\\s*${bot}\\b`, "i").test(res.body));
  if (missing.length) WARN("B5", "/robots.txt", `AI crawlers not named explicitly: ${missing.join(", ")}`);
}

async function checkLlmsTxt() {
  const res = await get(`${base}/llms.txt`);
  if (!res.ok) return WARN("I1", "/llms.txt", `Returned ${res.status || res.error} — add app/llms.txt/route.ts`);
  if (!/text\/plain/i.test(res.type)) WARN("I1", "/llms.txt", `Content-Type is "${res.type}", expected text/plain`);
  const needed = [
    [/^#\s+\S/m, "an H1 title"],
    [/^>\s+\S/m, "a blockquote summary"],
    [/^##\s+quick answer/im, "a '## Quick answer' section"],
    [/^##\s+.*schedule/im, "a schedule section"],
    [/^##\s+.*(faq|questions)/im, "an FAQ section"],
  ];
  for (const [re, label] of needed) {
    if (!re.test(res.body)) WARN("I3", "/llms.txt", `Missing ${label}`);
  }
  if (!new RegExp(`https?://[^\\s)]+/[a-z0-9-]+`, "i").test(res.body)) {
    WARN("I3", "/llms.txt", "No per-page URLs found — models can summarize but not cite specific pages");
  }
}

async function discoverUrls() {
  const res = await get(`${base}/sitemap.xml`);
  if (res.ok) {
    const locs = [...res.body.matchAll(/<loc>([^<]+)<\/loc>/gi)].map((m) => decode(m[1]));
    if (locs.length) return { source: "sitemap.xml", paths: [...new Set(locs.map(pathOf).filter(Boolean))] };
    FAIL("B1", "/sitemap.xml", "Sitemap has no <loc> entries");
  } else {
    FAIL("B1", "/sitemap.xml", `Returned ${res.status || res.error} — add app/sitemap.ts`);
  }
  const home = await get(base);
  const hrefs = [...home.body.matchAll(/<a\b[^>]*href=["']([^"']+)["']/gi)].map((m) => m[1]);
  const paths = [...new Set(["/"].concat(hrefs.map(pathOf).filter((p) => p && !p.startsWith("//"))))];
  return { source: "homepage links", paths };
}

// ---------------------------------------------------------------- run

const health = await get(base);
if (!health.ok) {
  console.error(`Cannot reach ${base} (${health.status || health.error}). Start the dev server first: npm run dev`);
  process.exit(2);
}

const { source, paths } = await discoverUrls();
await checkRobots();
await checkLlmsTxt();

const pages = [];
for (const path of paths) {
  const res = await get(`${base}${path === "/" ? "" : path}`);
  if (!res.ok) {
    FAIL("B2", path, `Returned ${res.status || res.error}`);
    continue;
  }
  const page = { path, data: extract(res.body), html: res.body };
  pages.push(page);
  checkPage(page);
}

// Cross-page uniqueness
for (const [field, code] of [["title", "A5"], ["description", "A6"]]) {
  const groups = new Map();
  for (const p of pages) {
    const v = p.data[field];
    if (!v) continue;
    groups.set(v, (groups.get(v) ?? []).concat(p.path));
  }
  for (const [value, where] of groups) {
    if (where.length > 1) {
      FAIL(code, where.join(", "), `Duplicate ${field}: "${value.slice(0, 70)}${value.length > 70 ? "…" : ""}"`);
    }
  }
}

// Internal link graph. A page with no inbound link anywhere is an orphan (FAIL);
// a page reachable only via a deeper hub page is a depth warning.
const linksFrom = new Map(
  pages.map((p) => [p.path, new Set([...p.html.matchAll(/<a\b[^>]*href=["']([^"']+)["']/gi)].map((m) => pathOf(m[1])).filter(Boolean))]),
);
const inbound = new Map(pages.map((p) => [p.path, 0]));
for (const [from, targets] of linksFrom) {
  for (const t of targets) if (inbound.has(t) && t !== from) inbound.set(t, inbound.get(t) + 1);
}
const orphans = pages.filter((p) => p.path !== "/" && inbound.get(p.path) === 0);
if (orphans.length) FAIL("B3", "-", `Orphan pages — no internal links point to them: ${orphans.map((p) => p.path).join(", ")}`);

const fromHome = linksFrom.get("/") ?? new Set();
const deep = pages.filter((p) => p.path !== "/" && inbound.get(p.path) > 0 && !fromHome.has(p.path));
if (deep.length) {
  WARN("B3", "/", `Reachable only via a hub page, not linked from the homepage: ${deep.map((p) => p.path).join(", ")}`);
}

// A full mesh (every city cross-linking every other) is the reference pattern.
const cityPages = pages.filter((p) => p.path !== "/" && p.eventCount);
if (cityPages.length > 2) {
  const weak = cityPages.filter((p) => inbound.get(p.path) < Math.ceil(cityPages.length / 2));
  if (weak.length) {
    INFO("B3", "-", `Thin inbound linking (< half the city pages link to them): ${weak.map((p) => `${p.path}(${inbound.get(p.path)})`).join(", ")}`);
  }
}

const origins = new Set(pages.map((p) => (p.data.canonical && isAbsolute(p.data.canonical) ? new URL(p.data.canonical).origin : null)).filter(Boolean));
if (origins.size === 1 && !base.startsWith([...origins][0])) {
  INFO("A4", "-", `Canonicals point at ${[...origins][0]} while auditing ${base} — expected on localhost`);
}

// ---------------------------------------------------------------- report

const counts = { FAIL: 0, WARN: 0, INFO: 0 };
findings.forEach((f) => counts[f.level]++);

if (asJson) {
  console.log(JSON.stringify({ base, source, pages: pages.map((p) => ({ path: p.path, title: p.data.title, ldTypes: p.ldTypes, eventCount: p.eventCount })), findings, counts }, null, 2));
} else {
  console.log(`\nSEO audit — ${base}`);
  console.log(`${pages.length} page(s) via ${source}\n`);
  for (const level of ["FAIL", "WARN", "INFO"]) {
    const group = findings.filter((f) => f.level === level);
    if (!group.length) continue;
    console.log(`${level} (${group.length})`);
    for (const f of group) console.log(`  ${f.code.padEnd(4)} ${f.where}\n       ${f.message}`);
    console.log("");
  }
  console.log("Pages");
  for (const p of pages) {
    const ld = p.ldTypes?.length ? p.ldTypes.join(", ") : "no schema";
    console.log(`  ${p.path.padEnd(18)} ${String(p.data.title ?? "(no title)").slice(0, 58)}`);
    console.log(`  ${"".padEnd(18)} ${ld}${p.eventCount ? ` · ${p.eventCount} event(s)` : ""}`);
  }
  console.log(`\n${counts.FAIL} FAIL · ${counts.WARN} WARN · ${counts.INFO} INFO`);
  if (!counts.FAIL) console.log("No blocking issues. Now run the judgment checks in references/audit-checklist.md.");
}

process.exit(counts.FAIL ? 1 : 0);