@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 +188 -7
- package/README.md +23 -8
- package/dist/components/Seo.astro +117 -6
- package/dist/components/seo-context.d.ts +105 -0
- package/dist/components/seo-context.d.ts.map +1 -0
- package/dist/components/seo-context.js +189 -0
- package/dist/components/seo-context.js.map +1 -0
- package/dist/components/seo-props.d.ts +4 -67
- package/dist/components/seo-props.d.ts.map +1 -1
- package/dist/components/seo-props.js +4 -233
- package/dist/components/seo-props.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/integration.d.ts +37 -0
- package/dist/integration.d.ts.map +1 -1
- package/dist/integration.js +36 -6
- package/dist/integration.js.map +1 -1
- package/dist/llms-txt.d.ts +51 -0
- package/dist/llms-txt.d.ts.map +1 -0
- package/dist/llms-txt.js +53 -0
- package/dist/llms-txt.js.map +1 -0
- package/package.json +2 -3
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
|
-
### `
|
|
2579
|
+
### `buildSeoContext`
|
|
2548
2580
|
|
|
2549
|
-
Pure-TS function that powers `<Seo>` internally.
|
|
2550
|
-
|
|
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 {
|
|
2587
|
+
import { buildSeoContext } from '@jdevalk/astro-seo-graph';
|
|
2554
2588
|
|
|
2555
|
-
const
|
|
2556
|
-
//
|
|
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
|
-
│
|
|
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
|
-
| **`
|
|
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,
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
12
|
-
<
|
|
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"}
|