@jdevalk/astro-seo-graph 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -24,6 +24,38 @@ Two packages:
24
24
 
25
25
  ---
26
26
 
27
+ ## Contents
28
+
29
+ **Schema core** — concepts and builders for the JSON-LD graph:
30
+
31
+ - [Architecture](#architecture)
32
+ - [Installation](#installation)
33
+ - [The @id system](#the-id-system)
34
+ - [Piece builders reference](#piece-builders-reference)
35
+
36
+ **Recipes and patterns** — how to model real sites:
37
+
38
+ - [Site type recipes](#site-type-recipes)
39
+ - [Trust and credibility signals](#trust-and-credibility-signals)
40
+ - [Choosing the right Article subtype](#choosing-the-right-article-subtype)
41
+ - [Actions: telling agents what they can do](#actions-telling-agents-what-they-can-do)
42
+ - [Multi-type entities](#multi-type-entities)
43
+ - [Rich Organization patterns](#rich-organization-patterns)
44
+ - [Rich Person patterns](#rich-person-patterns)
45
+ - [Reference implementations](#reference-implementations)
46
+
47
+ **Astro integration** — runtime component and build-time checks:
48
+
49
+ - [Astro integration guide](#astro-integration-guide) — the `<Seo>` component, hreflang, schema endpoints
50
+ - [Build-time integration](#build-time-integration) — `seoGraph()` hook, H1 validation, metadata uniqueness, IndexNow, `llms.txt`
51
+ - [Complete integration example](#complete-integration-example)
52
+ - [Advanced patterns](#advanced-patterns)
53
+ - [Common mistakes](#common-mistakes)
54
+ - [Validating your output](#validating-your-output)
55
+ - [Repository structure](#repository-structure)
56
+
57
+ ---
58
+
27
59
  ## Architecture
28
60
 
29
61
  ```
@@ -2544,18 +2576,24 @@ const blog = defineCollection({
2544
2576
  }
2545
2577
  ```
2546
2578
 
2547
- ### `buildAstroSeoProps`
2579
+ ### `buildSeoContext`
2548
2580
 
2549
- Pure-TS function that powers `<Seo>` internally. Use when you want to feed a
2550
- different head component:
2581
+ Pure-TS function that powers `<Seo>` internally. Returns a flat,
2582
+ render-ready normalization of `SeoProps` (resolved title, canonical, OG
2583
+ fields, hreflang entries, robots directives, twitter overrides). Use when
2584
+ you want to render the head yourself:
2551
2585
 
2552
2586
  ```ts
2553
- import { buildAstroSeoProps } from '@jdevalk/astro-seo-graph';
2587
+ import { buildSeoContext } from '@jdevalk/astro-seo-graph';
2554
2588
 
2555
- const astroSeoProps = buildAstroSeoProps(mySeoProps, Astro.url.href);
2556
- // Pass to astro-seo's <SEO> or any custom head component
2589
+ const ctx = buildSeoContext(mySeoProps, Astro.url.href);
2590
+ // ctx.title, ctx.canonical, ctx.og.*, ctx.twitter, ctx.hreflangs, ...
2557
2591
  ```
2558
2592
 
2593
+ The companion constant `ROBOTS_EXTRAS` exposes the
2594
+ `max-snippet:-1, max-image-preview:large, max-video-preview:-1`
2595
+ directives that `<Seo>` always appends to the robots tag.
2596
+
2559
2597
  ### `buildAlternateLinks`
2560
2598
 
2561
2599
  Pure helper for hreflang link generation. No Astro runtime needed — safe
@@ -2576,6 +2614,148 @@ const links = buildAlternateLinks({
2576
2614
 
2577
2615
  ---
2578
2616
 
2617
+ ## Build-time integration
2618
+
2619
+ `@jdevalk/astro-seo-graph/integration` exports a default `seoGraph()`
2620
+ function that returns an Astro integration. It hooks `astro:build:done`
2621
+ to run cross-page SEO checks and optional post-build actions against the
2622
+ static HTML output. SSR pages aren't on disk at build time, so they're
2623
+ not checked.
2624
+
2625
+ ```js
2626
+ // astro.config.mjs
2627
+ import { defineConfig } from 'astro/config';
2628
+ import seoGraph from '@jdevalk/astro-seo-graph/integration';
2629
+
2630
+ export default defineConfig({
2631
+ site: 'https://example.com',
2632
+ integrations: [
2633
+ seoGraph({
2634
+ // All options below are optional; listed with defaults.
2635
+ validateH1: true,
2636
+ validateUniqueMetadata: true,
2637
+ // indexNow: { ... },
2638
+ // llmsTxt: { ... },
2639
+ }),
2640
+ ],
2641
+ });
2642
+ ```
2643
+
2644
+ ### Options
2645
+
2646
+ | Option | Default | Purpose |
2647
+ | ------------------------ | ------- | --------------------------------------------------------------------- |
2648
+ | `validateH1` | `true` | Warn when a page has zero or >1 `<h1>` elements. |
2649
+ | `validateUniqueMetadata` | `true` | Warn when two pages share the same `<title>` or meta description. |
2650
+ | `indexNow` | — | Submit built URLs to IndexNow. Omit to disable. |
2651
+ | `llmsTxt` | — | Generate `llms.txt` at the root of the build output. Omit to disable. |
2652
+
2653
+ ### H1 and metadata validation
2654
+
2655
+ `validateH1` flags pages with missing or duplicate `<h1>` elements — the
2656
+ most common on-page SEO/accessibility miss. `validateUniqueMetadata`
2657
+ flags `<title>` or `<meta name="description">` values that repeat across
2658
+ pages; duplicates hurt Google's ability to pick a canonical result and
2659
+ can only be spotted across the whole corpus.
2660
+
2661
+ Both extractors are exported for reuse:
2662
+
2663
+ ```ts
2664
+ import {
2665
+ countH1s,
2666
+ extractTitle,
2667
+ extractMetaDescription,
2668
+ } from '@jdevalk/astro-seo-graph/integration';
2669
+ ```
2670
+
2671
+ ### IndexNow submission
2672
+
2673
+ ```js
2674
+ seoGraph({
2675
+ indexNow: {
2676
+ key: process.env.INDEXNOW_KEY!, // 8–128 hex chars
2677
+ host: 'example.com',
2678
+ siteUrl: 'https://example.com',
2679
+ // keyLocation?: defaults to https://<host>/<key>.txt
2680
+ // endpoint?: defaults to api.indexnow.org
2681
+ // filter?: (url) => boolean — drop URLs before submission
2682
+ },
2683
+ });
2684
+ ```
2685
+
2686
+ Only URLs on `host` are submitted. `index.html` paths are rewritten to
2687
+ their trailing-slash form.
2688
+
2689
+ **Deploy the key file first.** IndexNow verifies ownership by fetching
2690
+ `https://<host>/<key>.txt` on every submission. Submissions sent before
2691
+ the key is reachable in production get rejected (HTTP 403) and the key
2692
+ is treated as invalid — you'll have to rotate it. Serve the key via
2693
+ `createIndexNowKeyRoute` (see below), deploy, confirm the `.txt` loads
2694
+ over HTTPS, _then_ enable `indexNow` in the integration.
2695
+
2696
+ ```ts
2697
+ // src/pages/[your-key-here].txt.ts
2698
+ import { createIndexNowKeyRoute } from '@jdevalk/astro-seo-graph';
2699
+
2700
+ export const GET = createIndexNowKeyRoute({ key: 'your-key-here' });
2701
+ ```
2702
+
2703
+ The filename (minus `.txt.ts`) must equal the key.
2704
+
2705
+ ### llms.txt generation
2706
+
2707
+ Generates an [`llms.txt`](https://llmstxt.org) file — a markdown summary
2708
+ of the site that LLMs can use as a concise entry point.
2709
+
2710
+ ```js
2711
+ seoGraph({
2712
+ llmsTxt: {
2713
+ title: 'Example Site',
2714
+ siteUrl: 'https://example.com',
2715
+ summary: 'A demo site about X, Y, and Z.',
2716
+ // details?: extra paragraphs between summary and sections
2717
+ // sections?: user-supplied sections (skips auto-collection)
2718
+ // filter?: (url) => boolean — drop URLs from auto-section
2719
+ // autoSectionName?: defaults to 'Pages'
2720
+ // outputPath?: defaults to 'llms.txt'
2721
+ },
2722
+ });
2723
+ ```
2724
+
2725
+ By default, one "Pages" section is auto-generated from every built HTML
2726
+ file's `<title>` + meta description. Supply `sections` to take full
2727
+ control of the structure:
2728
+
2729
+ ```js
2730
+ llmsTxt: {
2731
+ title: 'Example Site',
2732
+ siteUrl: 'https://example.com',
2733
+ sections: [
2734
+ {
2735
+ name: 'Docs',
2736
+ links: [
2737
+ { url: 'https://example.com/docs/intro/', title: 'Intro', description: 'Start here' },
2738
+ ],
2739
+ },
2740
+ { name: 'Blog', links: [/* ... */] },
2741
+ ],
2742
+ }
2743
+ ```
2744
+
2745
+ The renderer is also exported for non-Astro contexts:
2746
+
2747
+ ```ts
2748
+ import { renderLlmsTxt } from '@jdevalk/astro-seo-graph';
2749
+
2750
+ const markdown = renderLlmsTxt({
2751
+ title: 'Example',
2752
+ summary: 'A demo site.',
2753
+ sections: [{ name: 'Pages', links: [{ url: 'https://x/', title: 'Home' }] }],
2754
+ });
2755
+ ```
2756
+
2757
+ ---
2758
+
2579
2759
  ## Complete integration example
2580
2760
 
2581
2761
  Here's how joost.blog wires everything together. Use this as a reference
@@ -3095,7 +3275,8 @@ seo-graph/
3095
3275
  │ ├── alternates.ts # buildAlternateLinks
3096
3276
  │ ├── content.ts # seoSchema, imageSchema
3097
3277
  │ └── components/
3098
- └── seo-props.ts # buildAstroSeoProps
3278
+ ├── seo-props.ts # SeoProps interface
3279
+ │ └── seo-context.ts # buildSeoContext
3099
3280
  ├── AGENTS.md # This file
3100
3281
  ├── README.md # Project overview
3101
3282
  └── pnpm-workspace.yaml
package/README.md CHANGED
@@ -11,6 +11,11 @@ component, and Zod helpers for content schemas.
11
11
  For detailed usage — including all builder signatures, site-type recipes, and
12
12
  schema.org best practices — see [AGENTS.md](https://github.com/jdevalk/seo-graph/blob/main/AGENTS.md).
13
13
 
14
+ > **Using an AI coding assistant?** The [`astro-seo` skill](https://github.com/jdevalk/skills/tree/main/astro-seo)
15
+ > audits and improves the full SEO setup of an Astro site — technical
16
+ > foundation, structured data, sitemaps, IndexNow, agent discovery, and
17
+ > more — and produces drop-in code routed through this package.
18
+
14
19
  ## What you get
15
20
 
16
21
  | API | Purpose |
@@ -20,11 +25,11 @@ schema.org best practices — see [AGENTS.md](https://github.com/jdevalk/seo-gra
20
25
  | **`createSchemaMap`** | Factory returning an `APIRoute` handler that emits a sitemap-style XML listing of your site's schema endpoints — the discovery point for agent crawlers. |
21
26
  | **`aggregate`** | Shared engine behind the endpoint factories. Walks a list of entries, runs a caller-supplied mapper, deduplicates by `@id`. |
22
27
  | **`seoSchema`, `imageSchema`** | Zod schemas for the `seo` and `image` fields on content collections. Import them into `src/content.config.ts`. |
23
- | **`buildAstroSeoProps`** | Pure-TS logic that powers `<Seo>` — exported for users who want to feed a different head component. |
28
+ | **`buildSeoContext`** | Pure-TS logic that powers `<Seo>` — returns a flat, normalized projection of `SeoProps` (resolved title, canonical, OG fields, hreflang entries, robots directives). Exported for users who want to render the head themselves. |
24
29
  | **`buildAlternateLinks`** | Pure helper that turns a `{ hreflang, href }` entry list into normalized `<link rel="alternate">` tags plus an `x-default`. Used internally by `<Seo>`'s `alternates` prop, and exported for non-Astro callers (e.g. CMS plugins feeding their own metadata pipelines). |
25
30
  | **`breadcrumbsFromUrl`** | Derives a breadcrumb trail from an Astro URL. Splits path segments, supports custom display names and segment skipping. Returns `BreadcrumbItem[]` ready to pass to `buildBreadcrumbList`. |
26
31
  | **`<FuzzyRedirect>`** | Drop-in 404 component. Fetches your sitemap, fuzzy-matches the current URL against known paths, and suggests or auto-redirects to the closest match. |
27
- | **`createIndexNowKeyRoute`** | Factory returning an `APIRoute` that serves the IndexNow key-verification file at `/<key>.txt`. Pair with the `indexNow` option on the integration to auto-submit built URLs on `astro:build:done`. |
32
+ | **`createIndexNowKeyRoute`** | Factory returning an `APIRoute` that serves the IndexNow key-verification file at `/<key>.txt`. Pair with the `indexNow` option on the integration to auto-submit built URLs on `astro:build:done`. |
28
33
 
29
34
  ## Installation
30
35
 
@@ -343,6 +348,8 @@ actions. Currently:
343
348
  built corpus.
344
349
  - Optionally submits built URLs to [IndexNow](https://www.indexnow.org)
345
350
  after the build completes.
351
+ - Optionally generates an [`llms.txt`](https://llmstxt.org) file at the
352
+ root of the build output, summarising the site for LLMs.
346
353
 
347
354
  ```js
348
355
  // astro.config.mjs
@@ -364,17 +371,25 @@ export default defineConfig({
364
371
 
365
372
  Options:
366
373
 
367
- | Prop | Default | Description |
368
- | ------------------------ | ------- | --------------------------------------------------------------- |
369
- | `validateH1` | `true` | Warn about pages without exactly one `<h1>` |
370
- | `validateUniqueMetadata` | `true` | Warn about duplicate `<title>` or meta description across pages |
371
- | `indexNow` | — | Submit built URLs to IndexNow. See below for sub-options. |
374
+ | Prop | Default | Description |
375
+ | ------------------------ | ------- | ----------------------------------------------------------------- |
376
+ | `validateH1` | `true` | Warn about pages without exactly one `<h1>` |
377
+ | `validateUniqueMetadata` | `true` | Warn about duplicate `<title>` or meta description across pages |
378
+ | `indexNow` | — | Submit built URLs to IndexNow. See below for sub-options. |
379
+ | `llmsTxt` | — | Generate `llms.txt` at the build root. See below for sub-options. |
372
380
 
373
381
  `indexNow` sub-options: `key` (8–128 hex chars), `host` (bare host, e.g.
374
382
  `example.com`), `siteUrl` (absolute origin), `keyLocation?` (defaults to
375
383
  `https://<host>/<key>.txt`), `endpoint?` (defaults to `api.indexnow.org`),
376
384
  `filter?` (drop URLs for which the callback returns `false`).
377
385
 
386
+ `llmsTxt` sub-options: `title` (required H1), `siteUrl` (required, used to
387
+ resolve crawled HTML paths), `summary?` (rendered as a blockquote),
388
+ `details?` (extra paragraphs), `sections?` (user-supplied sections; when
389
+ given, no pages are auto-collected), `filter?` (drop URLs from the
390
+ auto-generated section), `autoSectionName?` (defaults to `Pages`),
391
+ `outputPath?` (defaults to `llms.txt`).
392
+
378
393
  `index.html` paths are rewritten to their trailing-slash form. Only static
379
394
  pages are checked/submitted (SSR pages aren't on disk at build time).
380
395
 
@@ -399,7 +414,7 @@ own deploy hook.
399
414
  > sent before the key file is reachable in production get rejected
400
415
  > (HTTP 403) and the key is treated as invalid going forward — you'll
401
416
  > have to rotate it. Ship the route, deploy, confirm the `.txt` loads
402
- > over HTTPS, *then* enable `indexNow` in the integration.
417
+ > over HTTPS, _then_ enable `indexNow` in the integration.
403
418
 
404
419
  ## Validating your output
405
420
 
@@ -1,14 +1,125 @@
1
1
  ---
2
- import { SEO } from 'astro-seo';
3
- import { buildAstroSeoProps, type SeoProps } from './seo-props.js';
2
+ /**
3
+ * First-party <Seo> component. Consumes the flat `SeoContext` from
4
+ * `buildSeoContext()` and emits every `<head>` tag directly. No external
5
+ * `astro-seo` dependency.
6
+ *
7
+ * Tag order:
8
+ * 1. <title>
9
+ * 2. <link rel="canonical"> (omitted when noindex)
10
+ * 3. <meta name="description">
11
+ * 4. <meta name="robots"> (single merged tag)
12
+ * 5. og:title/type/image/url
13
+ * 6. og:description/site_name/locale/locale:alternate
14
+ * 7. og:image:alt/width/height (when og:image is set)
15
+ * 8. article:published_time/etc. (when ogType === 'article')
16
+ * 9. twitter:card/site/creator/...overrides
17
+ * 10. <link rel="alternate" hreflang> (incl. x-default)
18
+ * 11. <meta name="author">
19
+ * 12. extraLinks passthrough
20
+ * 13. extraMeta passthrough
21
+ * 14. <script type="application/ld+json"> (when graph is truthy)
22
+ */
23
+ import { buildSeoContext } from './seo-context.js';
24
+ import type { SeoProps } from './seo-props.js';
4
25
 
5
26
  const props = Astro.props as SeoProps;
6
- const astroSeo = buildAstroSeoProps(props, Astro.url.href);
27
+ const ctx = buildSeoContext(props, Astro.url.href);
28
+
29
+ function robotsContent(r: typeof ctx.robots): string {
30
+ const parts: string[] = [];
31
+ parts.push(r.noindex ? 'noindex' : 'index');
32
+ parts.push(r.nofollow ? 'nofollow' : 'follow');
33
+ if (r.extras) parts.push(r.extras);
34
+ return parts.join(', ');
35
+ }
7
36
  ---
8
37
 
9
- <SEO {...astroSeo} />
38
+ <title set:html={ctx.title} />
39
+
40
+ {ctx.canonical && <link rel="canonical" href={ctx.canonical} />}
41
+
42
+ {ctx.description && <meta name="description" content={ctx.description} />}
43
+
44
+ <meta name="robots" content={robotsContent(ctx.robots)} />
45
+
46
+ <meta property="og:title" content={ctx.og.title} />
47
+ <meta property="og:type" content={ctx.og.type} />
48
+ <meta property="og:image" content={ctx.og.image} />
49
+ <meta property="og:url" content={ctx.og.url} />
50
+
51
+ {ctx.og.description && <meta property="og:description" content={ctx.og.description} />}
52
+ {ctx.og.siteName && <meta property="og:site_name" content={ctx.og.siteName} />}
53
+ <meta property="og:locale" content={ctx.og.locale} />
54
+ {
55
+ ctx.og.localeAlternate?.map((entry) => (
56
+ <meta property="og:locale:alternate" content={entry} />
57
+ ))
58
+ }
59
+
60
+ {ctx.og.imageAlt && <meta property="og:image:alt" content={ctx.og.imageAlt} />}
61
+ {
62
+ ctx.og.imageWidth !== undefined && (
63
+ <meta property="og:image:width" content={String(ctx.og.imageWidth)} />
64
+ )
65
+ }
10
66
  {
11
- props.graph && (
12
- <script type="application/ld+json" set:html={JSON.stringify(props.graph)} />
67
+ ctx.og.imageHeight !== undefined && (
68
+ <meta property="og:image:height" content={String(ctx.og.imageHeight)} />
13
69
  )
14
70
  }
71
+
72
+ {
73
+ ctx.og.article && (
74
+ <>
75
+ {ctx.og.article.publishedTime && (
76
+ <meta property="article:published_time" content={ctx.og.article.publishedTime} />
77
+ )}
78
+ {ctx.og.article.modifiedTime && (
79
+ <meta property="article:modified_time" content={ctx.og.article.modifiedTime} />
80
+ )}
81
+ {ctx.og.article.expirationTime && (
82
+ <meta property="article:expiration_time" content={ctx.og.article.expirationTime} />
83
+ )}
84
+ {ctx.og.article.authors?.map((author) => (
85
+ <meta property="article:author" content={author} />
86
+ ))}
87
+ {ctx.og.article.tags?.map((tag) => <meta property="article:tag" content={tag} />)}
88
+ {ctx.og.article.section && (
89
+ <meta property="article:section" content={ctx.og.article.section} />
90
+ )}
91
+ {ctx.og.article.publisher && (
92
+ <meta property="article:publisher" content={ctx.og.article.publisher} />
93
+ )}
94
+ </>
95
+ )
96
+ }
97
+
98
+ {
99
+ ctx.twitter && (
100
+ <>
101
+ <meta name="twitter:card" content={ctx.twitter.card} />
102
+ {ctx.twitter.site && <meta name="twitter:site" content={ctx.twitter.site} />}
103
+ {ctx.twitter.creator && (
104
+ <meta name="twitter:creator" content={ctx.twitter.creator} />
105
+ )}
106
+ {ctx.twitter.title && <meta name="twitter:title" content={ctx.twitter.title} />}
107
+ {ctx.twitter.description && (
108
+ <meta name="twitter:description" content={ctx.twitter.description} />
109
+ )}
110
+ {ctx.twitter.image && <meta name="twitter:image" content={ctx.twitter.image} />}
111
+ {ctx.twitter.imageAlt && (
112
+ <meta name="twitter:image:alt" content={ctx.twitter.imageAlt} />
113
+ )}
114
+ </>
115
+ )
116
+ }
117
+
118
+ {ctx.hreflangs.map((entry) => <link rel="alternate" hreflang={entry.hreflang} href={entry.href} />)}
119
+
120
+ {ctx.authorName && <meta name="author" content={ctx.authorName} />}
121
+
122
+ {ctx.extraLinks.map((entry) => <link {...entry} />)}
123
+ {ctx.extraMeta.map((entry) => <meta {...entry} />)}
124
+
125
+ {ctx.graph && <script type="application/ld+json" set:html={JSON.stringify(ctx.graph)} />}
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Normalized, flat, render-ready projection of `SeoProps`. The `<Seo>`
3
+ * template consumes this directly — no further reshaping, no
4
+ * conditionals beyond "field present?". Keeping normalization here (pure
5
+ * TypeScript) means vitest can assert on every decision without touching
6
+ * Astro's renderer.
7
+ */
8
+ import { type AlternateLink } from '../alternates.js';
9
+ import type { SeoProps } from './seo-props.js';
10
+ /**
11
+ * Opted-in robots directives for maximum snippet and preview sizes in
12
+ * search results. Appended after any `noindex`/`nofollow` prefix.
13
+ */
14
+ export declare const ROBOTS_EXTRAS = "max-snippet:-1, max-image-preview:large, max-video-preview:-1";
15
+ /**
16
+ * Render-ready normalization of `SeoProps`. Every field has been resolved
17
+ * against defaults, every `Date` coerced to ISO string, every optional
18
+ * block either populated or omitted. Templates just iterate.
19
+ */
20
+ export interface SeoContext {
21
+ /** Final `<title>` text, with `titleTemplate` already applied. */
22
+ title: string;
23
+ /** Meta description. Omitted from output when undefined. */
24
+ description?: string;
25
+ /**
26
+ * Canonical URL. Already query-stripped (or preserved) per
27
+ * `preserveQueryParams`. `undefined` when the page is `noindex` —
28
+ * callers must not emit a canonical in that case.
29
+ */
30
+ canonical?: string;
31
+ /** Resolved robots directives. Always present (extras always apply). */
32
+ robots: {
33
+ noindex: boolean;
34
+ nofollow: boolean;
35
+ /** Directives beyond noindex/nofollow — typically `ROBOTS_EXTRAS`. */
36
+ extras: string;
37
+ };
38
+ /**
39
+ * Open Graph fields. `title`, `type`, `url` are always populated.
40
+ * `image` may be an empty string when no share image was provided.
41
+ */
42
+ og: {
43
+ title: string;
44
+ type: string;
45
+ image: string;
46
+ url: string;
47
+ description?: string;
48
+ siteName?: string;
49
+ locale: string;
50
+ localeAlternate?: readonly string[];
51
+ imageAlt?: string;
52
+ imageWidth?: number;
53
+ imageHeight?: number;
54
+ article?: {
55
+ publishedTime?: string;
56
+ modifiedTime?: string;
57
+ expirationTime?: string;
58
+ authors?: readonly string[];
59
+ tags?: readonly string[];
60
+ section?: string;
61
+ publisher?: string;
62
+ };
63
+ };
64
+ /** Twitter tags. Omitted entirely when the caller passed no `twitter` block. */
65
+ twitter?: {
66
+ card: 'summary' | 'summary_large_image' | 'app' | 'player';
67
+ site?: string;
68
+ creator?: string;
69
+ title?: string;
70
+ description?: string;
71
+ image?: string;
72
+ imageAlt?: string;
73
+ };
74
+ /**
75
+ * hreflang alternate entries (including `x-default`). Empty array when
76
+ * the caller passed no `alternates`, or when fewer than 2 entries
77
+ * survive validation.
78
+ */
79
+ hreflangs: readonly AlternateLink[];
80
+ /** Resolved author name for `<meta name="author">`. */
81
+ authorName?: string;
82
+ /** Passthrough extras. Empty arrays when not provided. */
83
+ extraLinks: ReadonlyArray<Record<string, string>>;
84
+ extraMeta: ReadonlyArray<Record<string, string>>;
85
+ /** JSON-LD graph to inject. `null`/`undefined` → skip the script tag. */
86
+ graph?: Record<string, unknown> | null;
87
+ }
88
+ /**
89
+ * Build the flat `SeoContext` from user-provided `SeoProps` and the
90
+ * current page URL (usually `Astro.url.href`). Pure function; no Astro
91
+ * runtime access.
92
+ *
93
+ * Invariants worth knowing:
94
+ * - `canonical` is omitted when `noindex` is true (Google recommendation).
95
+ * - `og:url` falls back to the query-stripped page URL when canonical
96
+ * is omitted, so share previews stay stable even on noindex pages.
97
+ * - Twitter override fields are suppressed when they equal their OG
98
+ * counterparts (Twitter falls back to OG automatically, so emitting
99
+ * the duplicate is noise).
100
+ * - `og:locale:alternate` is derived from `alternates` by converting
101
+ * hyphenated BCP 47 to underscore form and skipping entries that
102
+ * match the primary locale.
103
+ */
104
+ export declare function buildSeoContext(props: SeoProps, pageUrl: string): SeoContext;
105
+ //# sourceMappingURL=seo-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-context.d.ts","sourceRoot":"","sources":["../../src/components/seo-context.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAC3E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C;;;GAGG;AACH,eAAO,MAAM,aAAa,kEAAkE,CAAC;AAE7F;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACvB,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,MAAM,EAAE;QACJ,OAAO,EAAE,OAAO,CAAC;QACjB,QAAQ,EAAE,OAAO,CAAC;QAClB,sEAAsE;QACtE,MAAM,EAAE,MAAM,CAAC;KAClB,CAAC;IACF;;;OAGG;IACH,EAAE,EAAE;QACA,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;QACf,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,OAAO,CAAC,EAAE;YACN,aAAa,CAAC,EAAE,MAAM,CAAC;YACvB,YAAY,CAAC,EAAE,MAAM,CAAC;YACtB,cAAc,CAAC,EAAE,MAAM,CAAC;YACxB,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;YAC5B,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;YACzB,OAAO,CAAC,EAAE,MAAM,CAAC;YACjB,SAAS,CAAC,EAAE,MAAM,CAAC;SACtB,CAAC;KACL,CAAC;IACF,gFAAgF;IAChF,OAAO,CAAC,EAAE;QACN,IAAI,EAAE,SAAS,GAAG,qBAAqB,GAAG,KAAK,GAAG,QAAQ,CAAC;QAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,QAAQ,CAAC,EAAE,MAAM,CAAC;KACrB,CAAC;IACF;;;;OAIG;IACH,SAAS,EAAE,SAAS,aAAa,EAAE,CAAC;IACpC,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,SAAS,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjD,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1C;AAiBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,GAAG,UAAU,CAmI5E"}