@jdevalk/astro-seo-graph 0.1.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joost de Valk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @jdevalk/astro-seo-graph
2
+
3
+ Astro integration for [`@jdevalk/seo-graph-core`](../seo-graph-core). Ships a
4
+ `<Seo>` component, route factories for agent-ready schema endpoints, a
5
+ content-collection aggregator, and Zod helpers for content schemas.
6
+
7
+ > **Status:** pre-v1, alpha track. The API may still shift. See the
8
+ > [roadmap](https://github.com/jdevalk/seo-graph#roadmap) in the monorepo root.
9
+
10
+ ## What's in v0.1
11
+
12
+ | API | Purpose |
13
+ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
+ | **`<Seo>`** (`./Seo.astro`) | Single head component covering `<title>`, meta description, canonical, Open Graph, Twitter card, and optional JSON-LD `@graph`. Wraps [`astro-seo`](https://github.com/jonasmerlin/astro-seo) for the meta tags. |
15
+ | **`createSchemaEndpoint`** | Factory returning an Astro `APIRoute` handler that serves a corpus-wide JSON-LD `@graph` for a content collection. |
16
+ | **`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. |
17
+ | **`aggregate`** | Shared engine behind the endpoint factories. Walks a list of entries, runs a caller-supplied mapper, deduplicates by `@id`. |
18
+ | **`seoSchema`, `imageSchema`** | Zod schemas for the `seo` and `image` fields on content collections. Import them into `src/content.config.ts`. |
19
+ | **`buildAstroSeoProps`** | Pure-TS logic that powers `<Seo>` — exported for users who want to feed a different head component. |
20
+
21
+ ## Not in v0.1 (deferred to v0.2)
22
+
23
+ - **`createOgRenderer`** — a themeable `satori` + `sharp` wrapper for generating
24
+ Open Graph images at build time. Pulled out of v0.1 to keep the dep tree
25
+ free of native binaries. Keep using your own `og-image.ts` for now.
26
+
27
+ ## Installation
28
+
29
+ ```sh
30
+ pnpm add @jdevalk/astro-seo-graph @jdevalk/seo-graph-core
31
+ ```
32
+
33
+ `@jdevalk/seo-graph-core` is a direct dep of this package so it's installed
34
+ transitively, but depending on it explicitly lets you pin the version and
35
+ import piece builders directly.
36
+
37
+ ## `<Seo>` component
38
+
39
+ ```astro
40
+ ---
41
+ import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
42
+ import { buildSchemaGraph } from '../utils/schema';
43
+
44
+ const graph = buildSchemaGraph({
45
+ pageType: 'blogPost',
46
+ canonicalUrl: Astro.url.href,
47
+ title: 'My Post',
48
+ description: '…',
49
+ publishDate: new Date('2026-04-07'),
50
+ });
51
+ ---
52
+
53
+ <html>
54
+ <head>
55
+ <Seo
56
+ title="My Post"
57
+ titleTemplate="%s | Example"
58
+ description="…"
59
+ ogType="article"
60
+ ogImage="https://example.com/og/my-post.jpg"
61
+ ogImageAlt="My Post"
62
+ ogImageWidth={1200}
63
+ ogImageHeight={675}
64
+ siteName="Example"
65
+ twitter={{ site: '@example', creator: '@author' }}
66
+ article={{ publishedTime: new Date('2026-04-07'), tags: ['tech'] }}
67
+ graph={graph}
68
+ extraLinks={[
69
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
70
+ { rel: 'sitemap', href: '/sitemap-index.xml' },
71
+ ]}
72
+ />
73
+ </head>
74
+ <body>...</body>
75
+ </html>
76
+ ```
77
+
78
+ ## Schema endpoints
79
+
80
+ ```ts
81
+ // src/pages/schema/post.json.ts
82
+ import { getCollection } from 'astro:content';
83
+ import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
84
+ import { buildArticle, buildWebPage, makeIds } from '@jdevalk/seo-graph-core';
85
+
86
+ const ids = makeIds({ siteUrl: 'https://example.com' });
87
+
88
+ export const GET = createSchemaEndpoint({
89
+ entries: () => getCollection('blog'),
90
+ mapper: (post) => {
91
+ const url = `https://example.com/${post.id}/`;
92
+ return [
93
+ buildWebPage(
94
+ {
95
+ url,
96
+ name: post.data.title,
97
+ isPartOf: { '@id': ids.website },
98
+ breadcrumb: { '@id': ids.breadcrumb(url) },
99
+ datePublished: post.data.publishDate,
100
+ },
101
+ ids,
102
+ ),
103
+ buildArticle(
104
+ {
105
+ url,
106
+ isPartOf: { '@id': ids.webPage(url) },
107
+ author: { '@id': ids.person },
108
+ publisher: { '@id': ids.person },
109
+ headline: post.data.title,
110
+ description: post.data.excerpt ?? '',
111
+ datePublished: post.data.publishDate,
112
+ },
113
+ ids,
114
+ ),
115
+ ];
116
+ },
117
+ });
118
+ ```
119
+
120
+ ## Schema map discovery
121
+
122
+ ```ts
123
+ // src/pages/schemamap.xml.ts
124
+ import { createSchemaMap } from '@jdevalk/astro-seo-graph';
125
+
126
+ export const GET = createSchemaMap({
127
+ siteUrl: 'https://example.com',
128
+ entries: [
129
+ { path: '/schema/post.json', lastModified: new Date('2026-04-07') },
130
+ { path: '/schema/video.json', lastModified: new Date('2026-03-13') },
131
+ ],
132
+ });
133
+ ```
134
+
135
+ ## Zod content helpers
136
+
137
+ ```ts
138
+ // src/content.config.ts
139
+ import { defineCollection, z } from 'astro:content';
140
+ import { seoSchema } from '@jdevalk/astro-seo-graph';
141
+
142
+ const blog = defineCollection({
143
+ schema: ({ image }) =>
144
+ z.object({
145
+ title: z.string(),
146
+ publishDate: z.coerce.date(),
147
+ seo: seoSchema(image).optional(),
148
+ }),
149
+ });
150
+ ```
151
+
152
+ ## License
153
+
154
+ MIT © Joost de Valk
@@ -0,0 +1,27 @@
1
+ import { type GraphEntity } from '@jdevalk/seo-graph-core';
2
+ export interface AggregatorOptions<Entry> {
3
+ /** Content entries to walk, typically from Astro's `getCollection`. */
4
+ entries: readonly Entry[];
5
+ /**
6
+ * Map a single entry to an array of schema.org pieces. Return an
7
+ * empty array to skip the entry entirely (e.g. drafts). Pieces
8
+ * returned here are concatenated across entries; the aggregator
9
+ * deduplicates by `@id` with first-occurrence-wins semantics.
10
+ */
11
+ mapper: (entry: Entry) => ReadonlyArray<GraphEntity>;
12
+ }
13
+ export interface AggregatedGraph {
14
+ '@context': 'https://schema.org';
15
+ '@graph': GraphEntity[];
16
+ }
17
+ /**
18
+ * Walk a list of content entries, run the caller-supplied mapper over
19
+ * each one, concatenate all resulting pieces, and deduplicate by
20
+ * `@id`. Returns a ready-to-serialize `@graph` envelope.
21
+ *
22
+ * This is the shared engine behind `createSchemaEndpoint` — use it
23
+ * directly if you need custom wrapping, caching, or multiple-collection
24
+ * merging that the endpoint factories don't expose.
25
+ */
26
+ export declare function aggregate<Entry>(options: AggregatorOptions<Entry>): AggregatedGraph;
27
+ //# sourceMappingURL=aggregator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregator.d.ts","sourceRoot":"","sources":["../src/aggregator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEjF,MAAM,WAAW,iBAAiB,CAAC,KAAK;IACpC,uEAAuE;IACvE,OAAO,EAAE,SAAS,KAAK,EAAE,CAAC;IAC1B;;;;;OAKG;IACH,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,aAAa,CAAC,WAAW,CAAC,CAAC;CACxD;AAED,MAAM,WAAW,eAAe;IAC5B,UAAU,EAAE,oBAAoB,CAAC;IACjC,QAAQ,EAAE,WAAW,EAAE,CAAC;CAC3B;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,iBAAiB,CAAC,KAAK,CAAC,GAAG,eAAe,CAWnF"}
@@ -0,0 +1,23 @@
1
+ import { deduplicateByGraphId } from '@jdevalk/seo-graph-core';
2
+ /**
3
+ * Walk a list of content entries, run the caller-supplied mapper over
4
+ * each one, concatenate all resulting pieces, and deduplicate by
5
+ * `@id`. Returns a ready-to-serialize `@graph` envelope.
6
+ *
7
+ * This is the shared engine behind `createSchemaEndpoint` — use it
8
+ * directly if you need custom wrapping, caching, or multiple-collection
9
+ * merging that the endpoint factories don't expose.
10
+ */
11
+ export function aggregate(options) {
12
+ const pieces = [];
13
+ for (const entry of options.entries) {
14
+ for (const piece of options.mapper(entry)) {
15
+ pieces.push(piece);
16
+ }
17
+ }
18
+ return {
19
+ '@context': 'https://schema.org',
20
+ '@graph': deduplicateByGraphId(pieces),
21
+ };
22
+ }
23
+ //# sourceMappingURL=aggregator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregator.js","sourceRoot":"","sources":["../src/aggregator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAoB,MAAM,yBAAyB,CAAC;AAmBjF;;;;;;;;GAQG;AACH,MAAM,UAAU,SAAS,CAAQ,OAAiC;IAC9D,MAAM,MAAM,GAAkB,EAAE,CAAC;IACjC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;QAClC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;YACxC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvB,CAAC;IACL,CAAC;IACD,OAAO;QACH,UAAU,EAAE,oBAAoB;QAChC,QAAQ,EAAE,oBAAoB,CAAC,MAAM,CAAC;KACzC,CAAC;AACN,CAAC"}
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { SEO } from 'astro-seo';
3
+ import { buildAstroSeoProps, type SeoProps } from './seo-props.js';
4
+
5
+ const props = Astro.props as SeoProps;
6
+ const astroSeo = buildAstroSeoProps(props, Astro.url.href);
7
+ ---
8
+
9
+ <SEO {...astroSeo} />
10
+ {
11
+ props.graph && (
12
+ <script type="application/ld+json" set:html={JSON.stringify(props.graph)} />
13
+ )
14
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Pure TypeScript layer for the <Seo> component. The .astro file is a
3
+ * thin template that calls `buildAstroSeoProps` with the user's input
4
+ * and an Astro context (mostly `Astro.url.href`). Keeping the logic
5
+ * here makes it unit-testable with vitest — Astro's renderer isn't
6
+ * easily accessible from vitest.
7
+ */
8
+ /** Public props surface of the <Seo> component. */
9
+ export interface SeoProps {
10
+ /** Page title. Required. */
11
+ title: string;
12
+ /**
13
+ * Template for composing the full `<title>`. Use `%s` where the
14
+ * title should be substituted. Defaults to the raw `title`.
15
+ *
16
+ * @example `"%s | Joost.blog"` → `"My Post | Joost.blog"`
17
+ */
18
+ titleTemplate?: string;
19
+ /** Meta description. */
20
+ description?: string;
21
+ /** Canonical URL. Defaults to the current page URL (`Astro.url.href`). */
22
+ canonical?: string | URL;
23
+ /** Open Graph type. Defaults to `'website'`. */
24
+ ogType?: 'website' | 'article' | 'profile' | 'book';
25
+ /** Absolute URL of the share image. */
26
+ ogImage?: string;
27
+ /** Alt text for the share image. */
28
+ ogImageAlt?: string;
29
+ /** Width of the share image in pixels. */
30
+ ogImageWidth?: number;
31
+ /** Height of the share image in pixels. */
32
+ ogImageHeight?: number;
33
+ /** Site name shown in OG tags, e.g. `"Joost.blog"`. */
34
+ siteName?: string;
35
+ /** OG locale. Defaults to `'en_US'`. */
36
+ locale?: string;
37
+ /** Twitter card metadata. */
38
+ twitter?: {
39
+ /** Twitter handle of the site owner, e.g. `'@jdevalk'`. */
40
+ site?: string;
41
+ /** Twitter handle of the author. */
42
+ creator?: string;
43
+ /** Card type. Defaults to `'summary_large_image'`. */
44
+ card?: 'summary' | 'summary_large_image' | 'app' | 'player';
45
+ };
46
+ /**
47
+ * Article-specific OG metadata. Only emitted when `ogType` is
48
+ * `'article'`.
49
+ */
50
+ article?: {
51
+ publishedTime?: Date | string;
52
+ modifiedTime?: Date | string;
53
+ expirationTime?: Date | string;
54
+ authors?: readonly string[];
55
+ tags?: readonly string[];
56
+ section?: string;
57
+ };
58
+ /** Emit `<meta name="robots" content="noindex, follow">` when `true`. */
59
+ noindex?: boolean;
60
+ /**
61
+ * JSON-LD `@graph` envelope to inject as
62
+ * `<script type="application/ld+json">`. Typically the output of
63
+ * `buildSchemaGraph(...)` or `assembleGraph(...)` from
64
+ * `@jdevalk/seo-graph-core`. Pass `null` or omit to skip JSON-LD.
65
+ */
66
+ graph?: Record<string, unknown> | null;
67
+ /** Extra `<link>` tags (icons, sitemap, RSS alternate, etc.). */
68
+ extraLinks?: ReadonlyArray<Record<string, string>>;
69
+ /** Extra `<meta>` tags (author, custom fields). */
70
+ extraMeta?: ReadonlyArray<Record<string, string>>;
71
+ }
72
+ /**
73
+ * Shape consumed by `astro-seo`'s `<SEO>` component. We expose it as a
74
+ * type so tests can assert on exact keys / nesting rather than having
75
+ * to parse rendered HTML.
76
+ */
77
+ export interface AstroSeoProps {
78
+ title: string;
79
+ description?: string;
80
+ canonical: string;
81
+ noindex?: boolean;
82
+ openGraph: {
83
+ basic: {
84
+ title: string;
85
+ type: string;
86
+ image: string;
87
+ url: string;
88
+ };
89
+ optional?: {
90
+ description?: string;
91
+ siteName?: string;
92
+ locale?: string;
93
+ };
94
+ image?: {
95
+ alt?: string;
96
+ width?: number;
97
+ height?: number;
98
+ };
99
+ article?: {
100
+ publishedTime?: string;
101
+ modifiedTime?: string;
102
+ expirationTime?: string;
103
+ authors?: string[];
104
+ tags?: string[];
105
+ section?: string;
106
+ };
107
+ };
108
+ twitter?: {
109
+ card: 'summary' | 'summary_large_image' | 'app' | 'player';
110
+ site?: string;
111
+ creator?: string;
112
+ title: string;
113
+ description?: string;
114
+ image?: string;
115
+ imageAlt?: string;
116
+ };
117
+ extend?: {
118
+ link?: Array<Record<string, string>>;
119
+ meta?: Array<Record<string, string>>;
120
+ };
121
+ }
122
+ /**
123
+ * Project the public `SeoProps` onto the shape `astro-seo`'s `<SEO>`
124
+ * component expects. Pure function; no Astro runtime access.
125
+ *
126
+ * @param props Props as provided by the user.
127
+ * @param pageUrl The current page URL — usually `Astro.url.href`.
128
+ * Used as the canonical URL when `props.canonical` is
129
+ * not provided, and as the base for the OG `url`.
130
+ */
131
+ export declare function buildAstroSeoProps(props: SeoProps, pageUrl: string): AstroSeoProps;
132
+ //# sourceMappingURL=seo-props.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-props.d.ts","sourceRoot":"","sources":["../../src/components/seo-props.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure TypeScript layer for the <Seo> component. The .astro file is a
3
+ * thin template that calls `buildAstroSeoProps` with the user's input
4
+ * and an Astro context (mostly `Astro.url.href`). Keeping the logic
5
+ * here makes it unit-testable with vitest — Astro's renderer isn't
6
+ * easily accessible from vitest.
7
+ */
8
+ function toIsoString(value) {
9
+ if (value === undefined)
10
+ return undefined;
11
+ return value instanceof Date ? value.toISOString() : value;
12
+ }
13
+ /**
14
+ * Project the public `SeoProps` onto the shape `astro-seo`'s `<SEO>`
15
+ * component expects. Pure function; no Astro runtime access.
16
+ *
17
+ * @param props Props as provided by the user.
18
+ * @param pageUrl The current page URL — usually `Astro.url.href`.
19
+ * Used as the canonical URL when `props.canonical` is
20
+ * not provided, and as the base for the OG `url`.
21
+ */
22
+ export function buildAstroSeoProps(props, pageUrl) {
23
+ const fullTitle = props.titleTemplate
24
+ ? props.titleTemplate.replace('%s', props.title)
25
+ : props.title;
26
+ const canonical = props.canonical !== undefined ? props.canonical.toString() : pageUrl;
27
+ const ogType = props.ogType ?? 'website';
28
+ const ogImage = props.ogImage ?? '';
29
+ const openGraph = {
30
+ basic: {
31
+ title: fullTitle,
32
+ type: ogType,
33
+ image: ogImage,
34
+ url: canonical,
35
+ },
36
+ };
37
+ const optional = {};
38
+ if (props.description !== undefined)
39
+ optional.description = props.description;
40
+ if (props.siteName !== undefined)
41
+ optional.siteName = props.siteName;
42
+ optional.locale = props.locale ?? 'en_US';
43
+ openGraph.optional = optional;
44
+ const hasImageMeta = props.ogImageAlt !== undefined ||
45
+ props.ogImageWidth !== undefined ||
46
+ props.ogImageHeight !== undefined;
47
+ if (ogImage && hasImageMeta) {
48
+ const image = {};
49
+ if (props.ogImageAlt !== undefined)
50
+ image.alt = props.ogImageAlt;
51
+ if (props.ogImageWidth !== undefined)
52
+ image.width = props.ogImageWidth;
53
+ if (props.ogImageHeight !== undefined)
54
+ image.height = props.ogImageHeight;
55
+ openGraph.image = image;
56
+ }
57
+ if (ogType === 'article' && props.article) {
58
+ const article = {};
59
+ const published = toIsoString(props.article.publishedTime);
60
+ if (published !== undefined)
61
+ article.publishedTime = published;
62
+ const modified = toIsoString(props.article.modifiedTime);
63
+ if (modified !== undefined)
64
+ article.modifiedTime = modified;
65
+ const expiration = toIsoString(props.article.expirationTime);
66
+ if (expiration !== undefined)
67
+ article.expirationTime = expiration;
68
+ if (props.article.authors !== undefined)
69
+ article.authors = [...props.article.authors];
70
+ if (props.article.tags !== undefined)
71
+ article.tags = [...props.article.tags];
72
+ if (props.article.section !== undefined)
73
+ article.section = props.article.section;
74
+ openGraph.article = article;
75
+ }
76
+ const astroSeo = {
77
+ title: fullTitle,
78
+ canonical,
79
+ openGraph,
80
+ };
81
+ if (props.description !== undefined)
82
+ astroSeo.description = props.description;
83
+ if (props.noindex)
84
+ astroSeo.noindex = true;
85
+ if (props.twitter !== undefined) {
86
+ const twitter = {
87
+ card: props.twitter.card ?? 'summary_large_image',
88
+ title: fullTitle,
89
+ };
90
+ if (props.twitter.site !== undefined)
91
+ twitter.site = props.twitter.site;
92
+ if (props.twitter.creator !== undefined)
93
+ twitter.creator = props.twitter.creator;
94
+ if (props.description !== undefined)
95
+ twitter.description = props.description;
96
+ if (ogImage)
97
+ twitter.image = ogImage;
98
+ if (props.ogImageAlt !== undefined)
99
+ twitter.imageAlt = props.ogImageAlt;
100
+ astroSeo.twitter = twitter;
101
+ }
102
+ if ((props.extraLinks !== undefined && props.extraLinks.length > 0) ||
103
+ (props.extraMeta !== undefined && props.extraMeta.length > 0)) {
104
+ const extend = {};
105
+ if (props.extraLinks !== undefined && props.extraLinks.length > 0) {
106
+ extend.link = props.extraLinks.map((link) => ({ ...link }));
107
+ }
108
+ if (props.extraMeta !== undefined && props.extraMeta.length > 0) {
109
+ extend.meta = props.extraMeta.map((meta) => ({ ...meta }));
110
+ }
111
+ astroSeo.extend = extend;
112
+ }
113
+ return astroSeo;
114
+ }
115
+ //# sourceMappingURL=seo-props.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-props.js","sourceRoot":"","sources":["../../src/components/seo-props.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,87 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Astro content-collection `image` function signature, captured as `any`
4
+ * to avoid importing from the `astro:content` virtual module (which
5
+ * only resolves inside an Astro project, not in a standalone package).
6
+ * Users pass through their own `image` from Astro's schema callback.
7
+ */
8
+ type AstroImageFunction = any;
9
+ /**
10
+ * Zod schema for an image field on a content entry. Pair with Astro's
11
+ * `image()` from the collection schema factory so the image is processed
12
+ * through Astro's asset pipeline.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { defineCollection, z } from 'astro:content';
17
+ * import { imageSchema } from '@jdevalk/astro-seo-graph';
18
+ *
19
+ * const blog = defineCollection({
20
+ * schema: ({ image }) =>
21
+ * z.object({
22
+ * title: z.string(),
23
+ * featureImage: imageSchema(image).optional(),
24
+ * }),
25
+ * });
26
+ * ```
27
+ */
28
+ export declare function imageSchema(image: AstroImageFunction): z.ZodObject<{
29
+ src: any;
30
+ alt: z.ZodOptional<z.ZodString>;
31
+ }, "strip", z.ZodTypeAny, {
32
+ src?: any;
33
+ alt?: string | undefined;
34
+ }, {
35
+ src?: any;
36
+ alt?: string | undefined;
37
+ }>;
38
+ /**
39
+ * Zod schema for a nested `seo` field holding per-entry SEO overrides:
40
+ * title, description, share image, and page type. Enforces reasonable
41
+ * length limits (5–120 chars for title, 15–160 for description) as a
42
+ * lint.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const blog = defineCollection({
47
+ * schema: ({ image }) =>
48
+ * z.object({
49
+ * title: z.string(),
50
+ * seo: seoSchema(image).optional(),
51
+ * }),
52
+ * });
53
+ * ```
54
+ */
55
+ export declare function seoSchema(image: AstroImageFunction): z.ZodObject<{
56
+ title: z.ZodOptional<z.ZodString>;
57
+ description: z.ZodOptional<z.ZodString>;
58
+ image: z.ZodOptional<z.ZodObject<{
59
+ src: any;
60
+ alt: z.ZodOptional<z.ZodString>;
61
+ }, "strip", z.ZodTypeAny, {
62
+ src?: any;
63
+ alt?: string | undefined;
64
+ }, {
65
+ src?: any;
66
+ alt?: string | undefined;
67
+ }>>;
68
+ pageType: z.ZodDefault<z.ZodEnum<["website", "article"]>>;
69
+ }, "strip", z.ZodTypeAny, {
70
+ pageType: "website" | "article";
71
+ title?: string | undefined;
72
+ description?: string | undefined;
73
+ image?: {
74
+ src?: any;
75
+ alt?: string | undefined;
76
+ } | undefined;
77
+ }, {
78
+ title?: string | undefined;
79
+ description?: string | undefined;
80
+ image?: {
81
+ src?: any;
82
+ alt?: string | undefined;
83
+ } | undefined;
84
+ pageType?: "website" | "article" | undefined;
85
+ }>;
86
+ export {};
87
+ //# sourceMappingURL=content-helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-helpers.d.ts","sourceRoot":"","sources":["../src/content-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;GAKG;AAEH,KAAK,kBAAkB,GAAG,GAAG,CAAC;AAE9B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,kBAAkB;;;;;;;;;GAKpD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAOlD"}
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Zod schema for an image field on a content entry. Pair with Astro's
4
+ * `image()` from the collection schema factory so the image is processed
5
+ * through Astro's asset pipeline.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { defineCollection, z } from 'astro:content';
10
+ * import { imageSchema } from '@jdevalk/astro-seo-graph';
11
+ *
12
+ * const blog = defineCollection({
13
+ * schema: ({ image }) =>
14
+ * z.object({
15
+ * title: z.string(),
16
+ * featureImage: imageSchema(image).optional(),
17
+ * }),
18
+ * });
19
+ * ```
20
+ */
21
+ export function imageSchema(image) {
22
+ return z.object({
23
+ src: image(),
24
+ alt: z.string().optional(),
25
+ });
26
+ }
27
+ /**
28
+ * Zod schema for a nested `seo` field holding per-entry SEO overrides:
29
+ * title, description, share image, and page type. Enforces reasonable
30
+ * length limits (5–120 chars for title, 15–160 for description) as a
31
+ * lint.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const blog = defineCollection({
36
+ * schema: ({ image }) =>
37
+ * z.object({
38
+ * title: z.string(),
39
+ * seo: seoSchema(image).optional(),
40
+ * }),
41
+ * });
42
+ * ```
43
+ */
44
+ export function seoSchema(image) {
45
+ return z.object({
46
+ title: z.string().min(5).max(120).optional(),
47
+ description: z.string().min(15).max(160).optional(),
48
+ image: imageSchema(image).optional(),
49
+ pageType: z.enum(['website', 'article']).default('website'),
50
+ });
51
+ }
52
+ //# sourceMappingURL=content-helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-helpers.js","sourceRoot":"","sources":["../src/content-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAWxB;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,WAAW,CAAC,KAAyB;IACjD,OAAO,CAAC,CAAC,MAAM,CAAC;QACZ,GAAG,EAAE,KAAK,EAAE;QACZ,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;KAC7B,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,SAAS,CAAC,KAAyB;IAC/C,OAAO,CAAC,CAAC,MAAM,CAAC;QACZ,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE;QAC5C,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE;QACnD,KAAK,EAAE,WAAW,CAAC,KAAK,CAAC,CAAC,QAAQ,EAAE;QACpC,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC;KAC9D,CAAC,CAAC;AACP,CAAC"}
@@ -0,0 +1,8 @@
1
+ export { aggregate } from './aggregator.js';
2
+ export type { AggregatorOptions, AggregatedGraph } from './aggregator.js';
3
+ export { createSchemaEndpoint, createSchemaMap } from './routes.js';
4
+ export type { SchemaEndpointOptions, SchemaMapEntry, SchemaMapOptions } from './routes.js';
5
+ export { seoSchema, imageSchema } from './content-helpers.js';
6
+ export { buildAstroSeoProps } from './components/seo-props.js';
7
+ export type { SeoProps, AstroSeoProps } from './components/seo-props.js';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAE1E,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACpE,YAAY,EAAE,qBAAqB,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE3F,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAE9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @jdevalk/astro-seo-graph — Astro integration for agent-ready schema.org SEO.
2
+ //
3
+ // The `<Seo>` component lives at `./Seo.astro` and must be imported via
4
+ // the subpath export: `import Seo from '@jdevalk/astro-seo-graph/Seo.astro'`.
5
+ // Its props surface is `SeoProps`, re-exported below.
6
+ export { aggregate } from './aggregator.js';
7
+ export { createSchemaEndpoint, createSchemaMap } from './routes.js';
8
+ export { seoSchema, imageSchema } from './content-helpers.js';
9
+ export { buildAstroSeoProps } from './components/seo-props.js';
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,sDAAsD;AAEtD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAG5C,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGpE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAE9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC"}
@@ -0,0 +1,94 @@
1
+ import type { APIRoute } from 'astro';
2
+ import type { GraphEntity } from '@jdevalk/seo-graph-core';
3
+ export interface SchemaEndpointOptions<Entry> {
4
+ /**
5
+ * Async source of content entries. Usually a thin wrapper around
6
+ * Astro's `getCollection`, e.g. `() => getCollection('blog')`, or
7
+ * a filtered variant: `() => (await getCollection('blog')).filter(isPublished)`.
8
+ */
9
+ entries: () => Promise<readonly Entry[]>;
10
+ /**
11
+ * Map a single entry to an array of schema.org pieces. The pieces
12
+ * are assembled into a single `@graph` via the aggregator and
13
+ * deduplicated by `@id`.
14
+ */
15
+ mapper: (entry: Entry) => ReadonlyArray<GraphEntity>;
16
+ /**
17
+ * `Cache-Control` header value. Defaults to `max-age=300` (5 min).
18
+ * Pass `null` to omit the header entirely.
19
+ */
20
+ cacheControl?: string | null;
21
+ /**
22
+ * `Content-Type` header value. Defaults to `application/ld+json`.
23
+ */
24
+ contentType?: string;
25
+ /**
26
+ * JSON output indentation. Defaults to `2`. Pass `0` for compact
27
+ * output.
28
+ */
29
+ indent?: number;
30
+ }
31
+ /**
32
+ * Returns an Astro `APIRoute` that serves a corpus-wide schema.org
33
+ * `@graph` for a content collection as JSON-LD. Drop the returned
34
+ * handler into an `.ts` file under `src/pages/`.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * // src/pages/schema/post.json.ts
39
+ * import { getCollection } from 'astro:content';
40
+ * import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
41
+ * import { buildArticle, buildWebPage } from '@jdevalk/seo-graph-core';
42
+ *
43
+ * export const GET = createSchemaEndpoint({
44
+ * entries: () => getCollection('blog'),
45
+ * mapper: (post) => [
46
+ * buildWebPage({ url: `https://example.com/${post.id}/`, ... }, ids),
47
+ * buildArticle({ url: `https://example.com/${post.id}/`, ... }, ids),
48
+ * ],
49
+ * });
50
+ * ```
51
+ */
52
+ export declare function createSchemaEndpoint<Entry>(options: SchemaEndpointOptions<Entry>): APIRoute;
53
+ export interface SchemaMapEntry {
54
+ /**
55
+ * URL path relative to the site root, e.g. `/schema/post.json`.
56
+ * May start with or without a leading slash.
57
+ */
58
+ path: string;
59
+ /** When the resource at this path was last modified. */
60
+ lastModified: Date;
61
+ /** Defaults to `'daily'`. */
62
+ changeFreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
63
+ /** 0.0–1.0. Defaults to `0.8`. */
64
+ priority?: number;
65
+ }
66
+ export interface SchemaMapOptions {
67
+ /** Canonical site URL. Trailing slash is stripped. */
68
+ siteUrl: string;
69
+ /** One entry per schema endpoint. */
70
+ entries: readonly SchemaMapEntry[];
71
+ /** Defaults to `max-age=300`. Pass `null` to omit. */
72
+ cacheControl?: string | null;
73
+ }
74
+ /**
75
+ * Returns an Astro `APIRoute` that serves a sitemap-style XML listing
76
+ * of the site's schema.org endpoints. Agent crawlers can use this as
77
+ * a discovery point for a site's JSON-LD knowledge graph.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * // src/pages/schemamap.xml.ts
82
+ * import { createSchemaMap } from '@jdevalk/astro-seo-graph';
83
+ *
84
+ * export const GET = createSchemaMap({
85
+ * siteUrl: 'https://example.com',
86
+ * entries: [
87
+ * { path: '/schema/post.json', lastModified: new Date('2026-04-07') },
88
+ * { path: '/schema/video.json', lastModified: new Date('2026-03-13') },
89
+ * ],
90
+ * });
91
+ * ```
92
+ */
93
+ export declare function createSchemaMap(options: SchemaMapOptions): APIRoute;
94
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACtC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAG3D,MAAM,WAAW,qBAAqB,CAAC,KAAK;IACxC;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,SAAS,KAAK,EAAE,CAAC,CAAC;IACzC;;;;OAIG;IACH,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,aAAa,CAAC,WAAW,CAAC,CAAC;IACrD;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,qBAAqB,CAAC,KAAK,CAAC,GAAG,QAAQ,CAY3F;AAED,MAAM,WAAW,cAAc;IAC3B;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,YAAY,EAAE,IAAI,CAAC;IACnB,6BAA6B;IAC7B,UAAU,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAC;IACvF,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC7B,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;IACnC,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAWD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,QAAQ,CA8BnE"}
package/dist/routes.js ADDED
@@ -0,0 +1,92 @@
1
+ import { aggregate } from './aggregator.js';
2
+ /**
3
+ * Returns an Astro `APIRoute` that serves a corpus-wide schema.org
4
+ * `@graph` for a content collection as JSON-LD. Drop the returned
5
+ * handler into an `.ts` file under `src/pages/`.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // src/pages/schema/post.json.ts
10
+ * import { getCollection } from 'astro:content';
11
+ * import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
12
+ * import { buildArticle, buildWebPage } from '@jdevalk/seo-graph-core';
13
+ *
14
+ * export const GET = createSchemaEndpoint({
15
+ * entries: () => getCollection('blog'),
16
+ * mapper: (post) => [
17
+ * buildWebPage({ url: `https://example.com/${post.id}/`, ... }, ids),
18
+ * buildArticle({ url: `https://example.com/${post.id}/`, ... }, ids),
19
+ * ],
20
+ * });
21
+ * ```
22
+ */
23
+ export function createSchemaEndpoint(options) {
24
+ const cacheControl = options.cacheControl === undefined ? 'max-age=300' : options.cacheControl;
25
+ const contentType = options.contentType ?? 'application/ld+json';
26
+ const indent = options.indent ?? 2;
27
+ return async () => {
28
+ const entries = await options.entries();
29
+ const graph = aggregate({ entries, mapper: options.mapper });
30
+ const headers = { 'Content-Type': contentType };
31
+ if (cacheControl !== null)
32
+ headers['Cache-Control'] = cacheControl;
33
+ return new Response(JSON.stringify(graph, null, indent), { headers });
34
+ };
35
+ }
36
+ function escapeXml(value) {
37
+ return value
38
+ .replace(/&/g, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&apos;');
43
+ }
44
+ /**
45
+ * Returns an Astro `APIRoute` that serves a sitemap-style XML listing
46
+ * of the site's schema.org endpoints. Agent crawlers can use this as
47
+ * a discovery point for a site's JSON-LD knowledge graph.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * // src/pages/schemamap.xml.ts
52
+ * import { createSchemaMap } from '@jdevalk/astro-seo-graph';
53
+ *
54
+ * export const GET = createSchemaMap({
55
+ * siteUrl: 'https://example.com',
56
+ * entries: [
57
+ * { path: '/schema/post.json', lastModified: new Date('2026-04-07') },
58
+ * { path: '/schema/video.json', lastModified: new Date('2026-03-13') },
59
+ * ],
60
+ * });
61
+ * ```
62
+ */
63
+ export function createSchemaMap(options) {
64
+ const cacheControl = options.cacheControl === undefined ? 'max-age=300' : options.cacheControl;
65
+ const site = options.siteUrl.replace(/\/+$/, '');
66
+ return async () => {
67
+ const urls = options.entries
68
+ .map((entry) => {
69
+ const pathPart = entry.path.startsWith('/') ? entry.path : `/${entry.path}`;
70
+ const loc = escapeXml(`${site}${pathPart}`);
71
+ const lastmod = entry.lastModified.toISOString().split('T')[0];
72
+ const changefreq = entry.changeFreq ?? 'daily';
73
+ const priority = entry.priority ?? 0.8;
74
+ return ` <url contentType="structuredData/schema.org">
75
+ <loc>${loc}</loc>
76
+ <lastmod>${lastmod}</lastmod>
77
+ <changefreq>${changefreq}</changefreq>
78
+ <priority>${priority}</priority>
79
+ </url>`;
80
+ })
81
+ .join('\n');
82
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
83
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
84
+ ${urls}
85
+ </urlset>`;
86
+ const headers = { 'Content-Type': 'application/xml' };
87
+ if (cacheControl !== null)
88
+ headers['Cache-Control'] = cacheControl;
89
+ return new Response(xml, { headers });
90
+ };
91
+ }
92
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@jdevalk/astro-seo-graph",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Astro integration for @jdevalk/seo-graph-core. Seo component, route factories, content-collection aggregator, Zod content helpers.",
5
+ "keywords": [
6
+ "astro",
7
+ "astro-integration",
8
+ "astro-component",
9
+ "schema.org",
10
+ "json-ld",
11
+ "seo",
12
+ "agent-ready",
13
+ "structured-data"
14
+ ],
15
+ "author": "Joost de Valk <joost@joost.blog>",
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./Seo.astro": "./dist/components/Seo.astro"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/jdevalk/seo-graph.git",
35
+ "directory": "packages/astro-seo-graph"
36
+ },
37
+ "bugs": "https://github.com/jdevalk/seo-graph/issues",
38
+ "homepage": "https://github.com/jdevalk/seo-graph/tree/main/packages/astro-seo-graph#readme",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "peerDependencies": {
43
+ "astro": "^5.0.0 || ^6.0.0"
44
+ },
45
+ "dependencies": {
46
+ "astro-seo": "^1.1.0",
47
+ "schema-dts": "^2.0.0",
48
+ "zod": "^3.24.0",
49
+ "@jdevalk/seo-graph-core": "0.1.0-alpha.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.0.0",
53
+ "astro": "^6.0.5",
54
+ "typescript": "^5.6.0",
55
+ "vitest": "^2.0.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.build.json && mkdir -p dist/components && cp src/components/*.astro dist/components/",
59
+ "typecheck": "tsc -p tsconfig.json",
60
+ "test": "vitest run"
61
+ }
62
+ }