@jdevalk/astro-seo-graph 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/AGENTS.md +3078 -0
  2. package/README.md +13 -14
  3. package/package.json +4 -3
package/AGENTS.md ADDED
@@ -0,0 +1,3078 @@
1
+ # AGENTS.md — seo-graph
2
+
3
+ > This file is for AI coding agents (Claude Code, Cursor, Copilot, etc.)
4
+ > building sites that use `@jdevalk/seo-graph-core` and/or
5
+ > `@jdevalk/astro-seo-graph`. It explains what the library does, how the
6
+ > pieces fit together, and which schema.org entities to use for every
7
+ > common site type.
8
+
9
+ ## What this library does
10
+
11
+ seo-graph builds valid, linked schema.org JSON-LD `@graph` arrays from typed
12
+ inputs. Instead of hand-writing JSON-LD (error-prone) or copying snippets
13
+ from schema.org docs (inconsistent), you call piece builders that return
14
+ strongly-typed entities, then wrap them in a `@graph` envelope.
15
+
16
+ Two packages:
17
+
18
+ - **`@jdevalk/seo-graph-core`** — Pure TypeScript, no framework dependency.
19
+ Piece builders, ID factory, graph assembler, deduplication. Use this from
20
+ any runtime.
21
+ - **`@jdevalk/astro-seo-graph`** — Astro integration. `<Seo>` component,
22
+ route factories for schema endpoints, schema map for agent discovery,
23
+ Zod content helpers.
24
+
25
+ ---
26
+
27
+ ## Architecture
28
+
29
+ ```
30
+ ┌───────────────────────────────────────────────────┐
31
+ │ @jdevalk/seo-graph-core │
32
+ │ │
33
+ │ makeIds() IdFactory │
34
+ │ buildWebSite() buildWebPage() │
35
+ │ buildArticle() buildPiece() │
36
+ │ buildBreadcrumbList() │
37
+ │ buildImageObject() buildVideoObject() │
38
+ │ buildSiteNavigationElement() │
39
+ │ assembleGraph() │
40
+ │ deduplicateByGraphId() │
41
+ └──────────────┬────────────────────────────────────┘
42
+ │ used by
43
+ ┌──────────┴──────────┐
44
+ │ │
45
+ ▼ ▼
46
+ ┌────────────────┐ ┌───────────────────┐
47
+ │ astro-seo-graph│ │ Any other runtime │
48
+ │ │ │ (EmDash, Next.js, │
49
+ │ <Seo> │ │ SvelteKit, etc.) │
50
+ │ createSchema │ │ │
51
+ │ Endpoint() │ │ Use core directly │
52
+ │ createSchema │ └───────────────────┘
53
+ │ Map() │
54
+ │ aggregate() │
55
+ │ seoSchema() │
56
+ │ imageSchema() │
57
+ └────────────────┘
58
+ ```
59
+
60
+ **Key principle:** Core has no opinions about your content model, routing, or
61
+ page types. It gives you piece builders. _You_ decide which pieces to assemble
62
+ for each page. This file tells you which pieces to pick.
63
+
64
+ ---
65
+
66
+ ## Installation
67
+
68
+ ```sh
69
+ # Astro projects — install both
70
+ npm install @jdevalk/seo-graph-core @jdevalk/astro-seo-graph
71
+
72
+ # Non-Astro projects — core only
73
+ npm install @jdevalk/seo-graph-core
74
+ ```
75
+
76
+ ---
77
+
78
+ ## The @id system
79
+
80
+ Every entity in a JSON-LD `@graph` can have an `@id`. Other entities reference
81
+ it by `{ "@id": "..." }`. This is how the graph becomes _linked_ rather than
82
+ flat.
83
+
84
+ `makeIds()` creates an `IdFactory` that generates stable, deterministic `@id`
85
+ URIs for all entity types:
86
+
87
+ ```ts
88
+ import { makeIds } from '@jdevalk/seo-graph-core';
89
+
90
+ const ids = makeIds({
91
+ siteUrl: 'https://example.com',
92
+ personUrl: 'https://example.com/about/', // optional, defaults to siteUrl + '/'
93
+ });
94
+ ```
95
+
96
+ ### Available IDs
97
+
98
+ | Property/Method | Returns | Use for |
99
+ | ------------------------ | -------------------------------------------------------- | ------------------------------------ |
100
+ | `ids.person` | `https://example.com/about/#/schema.org/Person` | Site-wide Person entity |
101
+ | `ids.personImage` | `https://example.com/about/#/schema.org/Person/image` | Person's profile image |
102
+ | `ids.website` | `https://example.com/#/schema.org/WebSite` | Site-wide WebSite entity |
103
+ | `ids.navigation` | `https://example.com/#/schema.org/SiteNavigationElement` | Main navigation |
104
+ | `ids.organization(slug)` | `https://example.com/#/schema.org/Organization/{slug}` | Named organization |
105
+ | `ids.country(code)` | `https://example.com/#/schema.org/Country/{code}` | Country entity (ISO 3166) |
106
+ | `ids.webPage(url)` | The URL itself | WebPage entity (canonical URL = @id) |
107
+ | `ids.breadcrumb(url)` | `{url}#breadcrumb` | BreadcrumbList for a page |
108
+ | `ids.article(url)` | `{url}#article` | Article entity for a page |
109
+ | `ids.videoObject(url)` | `{url}#video` | VideoObject for a page |
110
+ | `ids.primaryImage(url)` | `{url}#primaryimage` | Primary image for a page |
111
+
112
+ ### How entities reference each other
113
+
114
+ Entities form a tree of references:
115
+
116
+ ```
117
+ WebSite
118
+ ├── publisher → Person or Organization
119
+ ├── hasPart → SiteNavigationElement
120
+
121
+ ├── Blog (optional, for sites with a blog)
122
+ │ └── publisher → Person or Organization
123
+
124
+ └── WebPage (one per URL)
125
+ ├── isPartOf → WebSite
126
+ ├── breadcrumb → BreadcrumbList
127
+ ├── primaryImage → ImageObject
128
+
129
+ ├── BlogPosting or Article (if blog post)
130
+ │ ├── isPartOf → WebPage, Blog
131
+ │ ├── author → Person
132
+ │ ├── publisher → Person or Organization
133
+ │ └── image → ImageObject
134
+
135
+ └── VideoObject (if video page)
136
+ └── isPartOf → WebPage
137
+ ```
138
+
139
+ **Blog vs. Article hierarchy:** `Blog` is a `CreativeWork` that represents the
140
+ blog as a publication. `BlogPosting` is a subtype of `Article`. A `BlogPosting`
141
+ can be `isPartOf` both its `WebPage` and the `Blog`. This lets agents understand
142
+ that a post belongs to a specific blog, not just a website. Use `Blog` when the
143
+ site has a distinct blog section; skip it for single-purpose blogs where the
144
+ blog _is_ the site.
145
+
146
+ **Rule:** Always use `{ '@id': ids.xxx }` to reference another entity. Never
147
+ inline the full entity inside another entity. The graph structure handles
148
+ resolution.
149
+
150
+ ---
151
+
152
+ ## Piece builders reference
153
+
154
+ Every builder takes an input object and returns a `GraphEntity` (a plain object
155
+ with `@type` and usually `@id`). The specialized builders (`buildWebSite`,
156
+ `buildWebPage`, `buildArticle`, etc.) also take the `IdFactory` as a second
157
+ parameter. The generic `buildPiece` builder takes only the input object — you
158
+ set the `@id` directly in the input.
159
+
160
+ ### buildWebSite
161
+
162
+ Creates the site-wide `WebSite` entity. Include exactly once per graph.
163
+
164
+ ```ts
165
+ buildWebSite(
166
+ {
167
+ url: 'https://example.com/', // required — site root URL
168
+ name: 'My Site', // required — site name
169
+ description: 'A site about...', // optional
170
+ publisher: { '@id': ids.person }, // required — Person or Organization ref
171
+ about: { '@id': ids.person }, // optional — what this site is about
172
+ inLanguage: 'en-US', // optional — default content language
173
+ hasPart: { '@id': ids.navigation }, // optional — navigation ref
174
+ // ...additional schema-dts properties accepted at top level
175
+ },
176
+ ids,
177
+ );
178
+ ```
179
+
180
+ **Adding a SearchAction** (recommended for sites with search):
181
+
182
+ Add a `potentialAction` with a `SearchAction` directly at the top level. This
183
+ tells search engines and agents how to search your site:
184
+
185
+ ```ts
186
+ buildWebSite(
187
+ {
188
+ url: 'https://example.com/',
189
+ name: 'My Site',
190
+ publisher: { '@id': ids.person },
191
+ potentialAction: {
192
+ '@type': 'SearchAction',
193
+ target: {
194
+ '@type': 'EntryPoint',
195
+ urlTemplate: 'https://example.com/?s={search_term_string}',
196
+ },
197
+ 'query-input': {
198
+ '@type': 'PropertyValueSpecification',
199
+ valueRequired: true,
200
+ valueName: 'search_term_string',
201
+ },
202
+ },
203
+ },
204
+ ids,
205
+ );
206
+ ```
207
+
208
+ This is the pattern used by most WordPress sites and many other CMSes.
209
+
210
+ ### buildWebPage
211
+
212
+ Creates a `WebPage`, `ProfilePage`, or `CollectionPage` entity.
213
+
214
+ ```ts
215
+ buildWebPage(
216
+ {
217
+ url: 'https://example.com/my-page/', // required — canonical URL (becomes @id)
218
+ name: 'My Page', // required — page title
219
+ isPartOf: { '@id': ids.website }, // required — WebSite ref
220
+ breadcrumb: { '@id': ids.breadcrumb(url) }, // optional — BreadcrumbList ref
221
+ inLanguage: 'en-US', // optional
222
+ datePublished: new Date('2026-01-15'), // optional — emitted as ISO string
223
+ dateModified: new Date('2026-03-01'), // optional
224
+ primaryImage: { '@id': ids.primaryImage(url) }, // optional — ImageObject ref
225
+ about: { '@id': ids.person }, // optional — for ProfilePage/homepage
226
+ copyrightHolder: { '@id': ids.person }, // optional — who holds the copyright
227
+ copyrightYear: 2026, // optional
228
+ copyrightNotice: '© 2026 Jane Doe.', // optional — human-readable copyright text
229
+ license: 'https://creativecommons.org/licenses/by/4.0/', // optional — license URL
230
+ isAccessibleForFree: true, // optional
231
+ potentialAction: [], // optional — defaults to ReadAction
232
+ // ...additional schema-dts properties accepted at top level
233
+ },
234
+ ids,
235
+ 'WebPage',
236
+ ); // third param: 'WebPage' | 'ProfilePage' | 'CollectionPage'
237
+ ```
238
+
239
+ **When to use which type:**
240
+
241
+ - `WebPage` — Default. Blog posts, regular pages, product pages.
242
+ - `ProfilePage` — About pages, author profiles.
243
+ - `CollectionPage` — Blog listing, category archives, tag pages, portfolios.
244
+
245
+ ### buildArticle
246
+
247
+ Creates an `Article` or any Article subtype (`BlogPosting`, `NewsArticle`,
248
+ etc.). Use for blog posts, news articles, tutorials.
249
+
250
+ ```ts
251
+ buildArticle(
252
+ {
253
+ url: 'https://example.com/my-post/', // required — canonical URL
254
+ isPartOf: { '@id': ids.webPage(url) }, // required — enclosing WebPage ref
255
+ author: { '@id': ids.person }, // required — Person ref
256
+ publisher: { '@id': ids.person }, // required — Person or Organization ref
257
+ headline: 'My Post Title', // required
258
+ description: 'A brief summary...', // required
259
+ inLanguage: 'en-US', // optional
260
+ datePublished: new Date('2026-01-15'), // required
261
+ dateModified: new Date('2026-03-01'), // optional
262
+ image: { '@id': ids.primaryImage(url) }, // optional — ImageObject ref
263
+ about: { '@id': ids.person }, // optional — what this article is about
264
+ articleSection: 'Technology', // optional — top-level category
265
+ wordCount: 1500, // optional
266
+ articleBody: 'The full text...', // optional — plain text, max ~10K chars
267
+ // ...additional schema-dts properties accepted at top level
268
+ },
269
+ ids,
270
+ 'Article',
271
+ ); // third param: 'Article' | 'BlogPosting' | 'NewsArticle' | 'TechArticle' | 'ScholarlyArticle' | 'Report'
272
+ ```
273
+
274
+ **The `type` parameter:** Pass the schema.org type name as the third argument.
275
+ Defaults to `'Article'`. Use `'BlogPosting'` for blog posts, `'NewsArticle'`
276
+ for journalism, `'TechArticle'` for technical docs, `'ScholarlyArticle'` for
277
+ academic papers, or `'Report'` for data/research reports.
278
+
279
+ ````
280
+
281
+ ### buildBreadcrumbList
282
+
283
+ Creates a `BreadcrumbList` with nested `ListItem` entries.
284
+
285
+ ```ts
286
+ buildBreadcrumbList({
287
+ url: 'https://example.com/blog/my-post/', // required — page this belongs to
288
+ items: [ // required — ordered root-first
289
+ { name: 'Home', url: 'https://example.com/' },
290
+ { name: 'Blog', url: 'https://example.com/blog/' },
291
+ { name: 'My Post', url: 'https://example.com/blog/my-post/' },
292
+ ],
293
+ // ...additional schema-dts properties accepted at top level
294
+ }, ids);
295
+ ````
296
+
297
+ **Rules:**
298
+
299
+ - First item should be the homepage.
300
+ - Last item should be the current page.
301
+ - Order is root → leaf.
302
+
303
+ ### buildImageObject
304
+
305
+ Creates an `ImageObject` entity.
306
+
307
+ ```ts
308
+ // Page-specific image (e.g. blog post feature image)
309
+ buildImageObject(
310
+ {
311
+ pageUrl: 'https://example.com/my-post/', // one of pageUrl or id required
312
+ url: 'https://example.com/images/post.jpg', // required — image file URL
313
+ width: 1200, // required
314
+ height: 630, // required
315
+ inLanguage: 'en-US', // optional
316
+ caption: 'A photo of...', // optional
317
+ // ...additional schema-dts properties accepted at top level
318
+ },
319
+ ids,
320
+ );
321
+
322
+ // Site-wide image (e.g. person photo, logo)
323
+ buildImageObject(
324
+ {
325
+ id: ids.personImage, // explicit @id override
326
+ url: 'https://example.com/joost.jpg',
327
+ width: 400,
328
+ height: 400,
329
+ },
330
+ ids,
331
+ );
332
+ ```
333
+
334
+ ### buildVideoObject
335
+
336
+ Creates a `VideoObject` entity. Has built-in YouTube support.
337
+
338
+ ```ts
339
+ buildVideoObject(
340
+ {
341
+ url: 'https://example.com/videos/my-talk/', // required — page URL
342
+ name: 'My Conference Talk', // required
343
+ description: 'A talk about...', // required
344
+ isPartOf: { '@id': ids.webPage(url) }, // required — enclosing WebPage ref
345
+ youtubeId: 'dQw4w9WgXcQ', // optional — auto-derives thumbnail + embed URLs
346
+ thumbnailUrl: '...', // optional — explicit override
347
+ embedUrl: '...', // optional — explicit override
348
+ uploadDate: new Date('2026-01-15'), // optional
349
+ duration: 'PT30M', // optional — ISO 8601
350
+ transcript: 'Full transcript text...', // optional
351
+ // ...additional schema-dts properties accepted at top level
352
+ },
353
+ ids,
354
+ );
355
+ ```
356
+
357
+ **YouTube convenience:** When `youtubeId` is provided:
358
+
359
+ - `thumbnailUrl` defaults to `https://img.youtube.com/vi/{id}/maxresdefault.jpg`
360
+ - `embedUrl` defaults to `https://www.youtube-nocookie.com/embed/{id}`
361
+
362
+ ### buildSiteNavigationElement
363
+
364
+ Creates a `SiteNavigationElement` with nested items.
365
+
366
+ ```ts
367
+ buildSiteNavigationElement(
368
+ {
369
+ name: 'Main navigation', // required
370
+ isPartOf: { '@id': ids.website }, // required — WebSite ref
371
+ items: [
372
+ // required — navigation links
373
+ { name: 'Home', url: 'https://example.com/' },
374
+ { name: 'Blog', url: 'https://example.com/blog/' },
375
+ { name: 'About', url: 'https://example.com/about/' },
376
+ ],
377
+ // ...additional schema-dts properties accepted at top level
378
+ },
379
+ ids,
380
+ );
381
+ ```
382
+
383
+ ### buildPiece
384
+
385
+ The generic typed builder for any schema.org type. This is the go-to builder
386
+ for `Person`, `Organization`, `Blog`, `Product`, `Recipe`, `Event`, `Course`,
387
+ `SoftwareApplication`, `VacationRental`, `FAQPage`, `PodcastSeries`,
388
+ `PodcastEpisode`, and any other schema.org type not covered by the specialized
389
+ builders (`buildWebSite`, `buildWebPage`, `buildArticle`, etc.).
390
+
391
+ Pass a `schema-dts` type as the generic parameter for full autocomplete.
392
+ The `@type` value in the input narrows union types to the matching leaf — so
393
+ `buildPiece<Product>` with `'@type': 'Product'` gives `ProductLeaf` autocomplete.
394
+ No need to import Leaf types separately.
395
+
396
+ Callers are responsible for setting `@id` using the `IdFactory` (e.g.
397
+ `ids.person`, `ids.organization('slug')`) or a custom ID string.
398
+
399
+ ```ts
400
+ import type { Person, Organization, Restaurant, Blog, Product, Recipe, Event } from 'schema-dts';
401
+
402
+ // Person (site-wide)
403
+ buildPiece<Person>({
404
+ '@type': 'Person',
405
+ '@id': ids.person,
406
+ name: 'Jane Doe',
407
+ url: 'https://example.com/about/',
408
+ image: { '@id': ids.personImage },
409
+ sameAs: ['https://twitter.com/janedoe', 'https://github.com/janedoe'],
410
+ jobTitle: 'Lead Engineer',
411
+ worksFor: [
412
+ {
413
+ '@type': 'EmployeeRole',
414
+ roleName: 'Lead Engineer',
415
+ startDate: '2022-01-01',
416
+ worksFor: { '@id': ids.organization('acme') },
417
+ },
418
+ ],
419
+ });
420
+
421
+ // Organization
422
+ buildPiece<Organization>({
423
+ '@type': 'Organization',
424
+ '@id': ids.organization('acme'),
425
+ name: 'Acme Corp',
426
+ url: 'https://acme.com/',
427
+ logo: 'https://acme.com/logo.png',
428
+ sameAs: ['https://twitter.com/acme'],
429
+ });
430
+
431
+ // Organization subtype (e.g. Restaurant) — use the subtype directly as the generic
432
+ buildPiece<Restaurant>({
433
+ '@type': 'Restaurant',
434
+ '@id': ids.organization('chez-example'),
435
+ name: 'Chez Example',
436
+ url: 'https://chezexample.com/',
437
+ servesCuisine: 'French',
438
+ priceRange: '$$$',
439
+ address: {
440
+ '@type': 'PostalAddress',
441
+ streetAddress: '123 Rue de la Paix',
442
+ addressLocality: 'Paris',
443
+ addressCountry: 'FR',
444
+ },
445
+ });
446
+
447
+ // Product
448
+ buildPiece<Product>({
449
+ '@type': 'Product',
450
+ '@id': `${url}#product`,
451
+ name: 'Running Shoe',
452
+ brand: 'Nike',
453
+ sku: 'ABC123',
454
+ offers: { '@type': 'Offer', price: 99.99, priceCurrency: 'USD' },
455
+ });
456
+
457
+ // Blog
458
+ buildPiece<Blog>({
459
+ '@type': 'Blog',
460
+ '@id': `${siteUrl}/blog/#blog`,
461
+ name: 'My Blog',
462
+ url: `${siteUrl}/blog/`,
463
+ publisher: { '@id': ids.person },
464
+ inLanguage: 'en-US',
465
+ });
466
+
467
+ // Recipe
468
+ buildPiece<Recipe>({
469
+ '@type': 'Recipe',
470
+ '@id': `${url}#recipe`,
471
+ name: 'Simple Pasta',
472
+ author: { '@id': ids.person },
473
+ prepTime: 'PT10M',
474
+ cookTime: 'PT20M',
475
+ totalTime: 'PT30M',
476
+ recipeYield: '4 servings',
477
+ recipeCategory: 'Main course',
478
+ recipeCuisine: 'Italian',
479
+ recipeIngredient: ['400g spaghetti', '200g guanciale', '4 egg yolks'],
480
+ recipeInstructions: [
481
+ { '@type': 'HowToStep', text: 'Boil the spaghetti.' },
482
+ { '@type': 'HowToStep', text: 'Fry the guanciale.' },
483
+ ],
484
+ });
485
+
486
+ // Event
487
+ buildPiece<Event>({
488
+ '@type': 'Event',
489
+ '@id': 'https://example.com/events/conf/#event',
490
+ name: 'JavaScript Conference 2026',
491
+ startDate: '2026-09-15T09:00:00+02:00',
492
+ endDate: '2026-09-17T18:00:00+02:00',
493
+ location: {
494
+ '@type': 'Place',
495
+ name: 'Congress Center',
496
+ },
497
+ });
498
+ ```
499
+
500
+ Without a generic, the input is untyped — any properties are accepted:
501
+
502
+ ```ts
503
+ buildPiece({
504
+ '@type': 'Event',
505
+ '@id': 'https://example.com/events/conf/#event',
506
+ name: 'JavaScript Conference 2026',
507
+ });
508
+ ```
509
+
510
+ **Always prefer the typed generic** (`buildPiece<Event>`) over the
511
+ untyped form. The generic gives you autocomplete for every property on the
512
+ chosen type, making it much harder to miss recommended fields like
513
+ `potentialAction`, `geo`, or `offers`.
514
+
515
+ ### assembleGraph
516
+
517
+ Wraps pieces in a `{ "@context": "https://schema.org", "@graph": [...] }`
518
+ envelope with first-wins deduplication by `@id`.
519
+
520
+ ```ts
521
+ import { assembleGraph } from '@jdevalk/seo-graph-core';
522
+
523
+ const graph = assembleGraph([
524
+ websitePiece,
525
+ personPiece,
526
+ webPagePiece,
527
+ articlePiece,
528
+ breadcrumbPiece,
529
+ ]);
530
+ ```
531
+
532
+ **Always call this last.** It handles deduplication: if multiple pages produce
533
+ the same `WebSite` or `Person` entity (same `@id`), the first occurrence wins.
534
+
535
+ **Dangling reference validation:** Pass `warnOnDanglingReferences: true` to
536
+ validate that every `{ '@id': '...' }` reference in the graph resolves to an
537
+ actual entity. This helps catch broken links — for example, a `WebSite`
538
+ referencing a `Person` that was never included in the pieces array.
539
+
540
+ ```ts
541
+ const graph = assembleGraph(pieces, { warnOnDanglingReferences: true });
542
+ // Warns: [seo-graph] Dangling reference in WebSite: { "@id": "..." } does not match any entity in the graph.
543
+ ```
544
+
545
+ ### deduplicateByGraphId
546
+
547
+ The dedup engine on its own, for custom assembly workflows.
548
+
549
+ ```ts
550
+ import { deduplicateByGraphId } from '@jdevalk/seo-graph-core';
551
+
552
+ const unique = deduplicateByGraphId(allPieces);
553
+ ```
554
+
555
+ ---
556
+
557
+ ## Site type recipes
558
+
559
+ Each recipe shows which pieces to include for a given page type. Copy the
560
+ pattern, adjust the data. Every recipe assumes you've already created an
561
+ `IdFactory` with `makeIds()`.
562
+
563
+ ### Personal blog
564
+
565
+ The most common case. A single-author blog with posts, categories, and an
566
+ about page.
567
+
568
+ **For every page** (site-wide entities):
569
+
570
+ - `buildWebSite` — publisher points to Person
571
+ - `buildPiece<Person>` — the blog author
572
+ - `buildImageObject` — person's profile photo (use `id: ids.personImage`)
573
+ - `buildPiece<Blog>` — a `Blog` entity representing the blog as a publication
574
+
575
+ The `Blog` entity is a `CreativeWork` that represents the blog as a whole,
576
+ separate from the `WebSite`. Individual `BlogPosting` entries reference the
577
+ Blog via `isPartOf`. This is the pattern used by jonoalderson.com.
578
+
579
+ ```ts
580
+ import type { Blog } from 'schema-dts';
581
+
582
+ const blogId = `${siteUrl}/blog/#blog`;
583
+
584
+ // Include on every page as a site-wide entity
585
+ buildPiece<Blog>({
586
+ '@type': 'Blog',
587
+ '@id': blogId,
588
+ name: 'My Blog',
589
+ description: 'Thoughts on web development and the open web.',
590
+ url: `${siteUrl}/blog/`,
591
+ publisher: { '@id': ids.person },
592
+ inLanguage: 'en-US',
593
+ }),
594
+ ```
595
+
596
+ **Blog post** (`/blog/my-post/`):
597
+
598
+ Use `BlogPosting` instead of `Article` and link it to the Blog:
599
+
600
+ ```ts
601
+ import type { Person, Blog } from 'schema-dts';
602
+
603
+ const blogId = `${siteUrl}/blog/#blog`;
604
+
605
+ const pieces = [
606
+ buildWebSite({ url: siteUrl, name: 'My Blog', publisher: { '@id': ids.person } }, ids),
607
+ buildPiece<Person>({ '@type': 'Person', '@id': ids.person, name: 'Jane Doe', url: aboutUrl, image: { '@id': ids.personImage }, sameAs: [...] }),
608
+ buildImageObject({ id: ids.personImage, url: profilePhotoUrl, width: 400, height: 400 }, ids),
609
+ buildPiece<Blog>({
610
+ '@type': 'Blog',
611
+ '@id': blogId,
612
+ name: 'My Blog',
613
+ url: `${siteUrl}/blog/`,
614
+ publisher: { '@id': ids.person },
615
+ }),
616
+ buildWebPage({ url, name: title, isPartOf: { '@id': ids.website }, breadcrumb: { '@id': ids.breadcrumb(url) }, datePublished, dateModified, primaryImage: { '@id': ids.primaryImage(url) } }, ids),
617
+ buildArticle({
618
+ url,
619
+ headline: title,
620
+ description,
621
+ datePublished,
622
+ dateModified,
623
+ author: { '@id': ids.person },
624
+ publisher: { '@id': ids.person },
625
+ isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }],
626
+ image: { '@id': ids.primaryImage(url) },
627
+ articleSection: category,
628
+ wordCount,
629
+ }, ids, 'BlogPosting'),
630
+ buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
631
+ buildImageObject({ pageUrl: url, url: featureImageUrl, width: 1200, height: 630 }, ids),
632
+ ];
633
+ const graph = assembleGraph(pieces);
634
+ ```
635
+
636
+ **Note:** The `isPartOf` array links the posting to both the `WebPage` and the
637
+ `Blog`. If you don't need the `Blog` link, just use
638
+ `isPartOf: { '@id': ids.webPage(url) }` directly.
639
+
640
+ **Blog listing** (`/blog/`):
641
+
642
+ ```ts
643
+ const pieces = [
644
+ // ...site-wide entities (including Blog)...
645
+ buildWebPage(
646
+ {
647
+ url,
648
+ name: 'Blog',
649
+ isPartOf: { '@id': ids.website },
650
+ breadcrumb: { '@id': ids.breadcrumb(url) },
651
+ about: { '@id': blogId },
652
+ },
653
+ ids,
654
+ 'CollectionPage',
655
+ ),
656
+ buildBreadcrumbList(
657
+ {
658
+ url,
659
+ items: [
660
+ { name: 'Home', url: siteUrl },
661
+ { name: 'Blog', url },
662
+ ],
663
+ },
664
+ ids,
665
+ ),
666
+ ];
667
+ ```
668
+
669
+ **Category archive** (`/blog/category/tech/`):
670
+
671
+ ```ts
672
+ const pieces = [
673
+ // ...site-wide entities...
674
+ buildWebPage(
675
+ {
676
+ url,
677
+ name: 'Technology',
678
+ isPartOf: { '@id': ids.website },
679
+ breadcrumb: { '@id': ids.breadcrumb(url) },
680
+ },
681
+ ids,
682
+ 'CollectionPage',
683
+ ),
684
+ buildBreadcrumbList(
685
+ {
686
+ url,
687
+ items: [
688
+ { name: 'Home', url: siteUrl },
689
+ { name: 'Blog', url: blogUrl },
690
+ { name: 'Technology', url },
691
+ ],
692
+ },
693
+ ids,
694
+ ),
695
+ ];
696
+ ```
697
+
698
+ **About page** (`/about/`):
699
+
700
+ ```ts
701
+ const pieces = [
702
+ // ...site-wide entities...
703
+ buildWebPage(
704
+ { url, name: 'About Jane', isPartOf: { '@id': ids.website }, about: { '@id': ids.person } },
705
+ ids,
706
+ 'ProfilePage',
707
+ ),
708
+ ];
709
+ ```
710
+
711
+ **Homepage** (`/`):
712
+
713
+ ```ts
714
+ const pieces = [
715
+ // ...site-wide entities...
716
+ buildWebPage(
717
+ {
718
+ url: siteUrl,
719
+ name: 'Jane Doe — My Blog',
720
+ isPartOf: { '@id': ids.website },
721
+ about: { '@id': ids.person },
722
+ },
723
+ ids,
724
+ 'CollectionPage',
725
+ ),
726
+ ];
727
+ ```
728
+
729
+ ---
730
+
731
+ ### Business / company blog
732
+
733
+ A multi-author blog owned by a company.
734
+
735
+ **Key difference from personal blog:** The `WebSite` publisher is an
736
+ `Organization`, not a `Person`. Individual authors are separate `Person`
737
+ entities.
738
+
739
+ ```ts
740
+ import type { Organization, Blog, Person } from 'schema-dts';
741
+
742
+ const ids = makeIds({ siteUrl: 'https://acme.com' });
743
+
744
+ // Site-wide
745
+ const blogId = 'https://acme.com/blog/#blog';
746
+ const siteEntities = [
747
+ buildPiece<Organization>({ '@type': 'Organization', '@id': ids.organization('acme'), name: 'Acme Corp', url: 'https://acme.com/', logo: logoUrl, sameAs: [...] }),
748
+ buildWebSite({ url: 'https://acme.com/', name: 'Acme Blog', publisher: { '@id': ids.organization('acme') } }, ids),
749
+ buildPiece<Blog>({
750
+ '@type': 'Blog',
751
+ '@id': blogId,
752
+ name: 'The Acme Blog',
753
+ url: 'https://acme.com/blog/',
754
+ publisher: { '@id': ids.organization('acme') },
755
+ }),
756
+ ];
757
+
758
+ // Per blog post — author is a separate Person (not site-wide ids.person)
759
+ const authorId = 'https://acme.com/team/jane/#person';
760
+ const postPieces = [
761
+ ...siteEntities,
762
+ buildPiece<Person>({ '@type': 'Person', '@id': authorId, name: 'Jane Doe', url: 'https://acme.com/team/jane/' }),
763
+ buildWebPage({ url, name: title, isPartOf: { '@id': ids.website }, datePublished }, ids),
764
+ buildArticle({
765
+ url,
766
+ headline: title,
767
+ description,
768
+ datePublished,
769
+ author: { '@id': authorId },
770
+ publisher: { '@id': ids.organization('acme') },
771
+ isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }],
772
+ }, ids, 'BlogPosting'),
773
+ buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
774
+ ];
775
+ ```
776
+
777
+ ---
778
+
779
+ ### E-commerce / product page
780
+
781
+ Use `buildPiece<Product>` for `Product` and `buildPiece<ProductGroup>` for `ProductGroup` entities.
782
+
783
+ **Simple product (single variant):**
784
+
785
+ ```ts
786
+ import type { Organization, Product } from 'schema-dts';
787
+
788
+ const ids = makeIds({ siteUrl: 'https://shop.example.com' });
789
+
790
+ const pieces = [
791
+ buildPiece<Organization>({
792
+ '@type': 'Organization',
793
+ '@id': ids.organization('shop'),
794
+ name: 'Example Shop',
795
+ url: siteUrl,
796
+ logo: logoUrl,
797
+ }),
798
+ buildWebSite(
799
+ { url: siteUrl, name: 'Example Shop', publisher: { '@id': ids.organization('shop') } },
800
+ ids,
801
+ ),
802
+ buildWebPage(
803
+ {
804
+ url,
805
+ name: productName,
806
+ isPartOf: { '@id': ids.website },
807
+ breadcrumb: { '@id': ids.breadcrumb(url) },
808
+ },
809
+ ids,
810
+ ),
811
+ buildBreadcrumbList(
812
+ {
813
+ url,
814
+ items: [
815
+ { name: 'Home', url: siteUrl },
816
+ { name: 'Shoes', url: categoryUrl },
817
+ { name: productName, url },
818
+ ],
819
+ },
820
+ ids,
821
+ ),
822
+ buildPiece<Product>({
823
+ '@type': 'Product',
824
+ '@id': `${url}#product`,
825
+ name: productName,
826
+ description: productDescription,
827
+ brand: 'Nike',
828
+ sku: 'ABC123',
829
+ offers: {
830
+ '@type': 'Offer',
831
+ price: 99.99,
832
+ priceCurrency: 'USD',
833
+ availability: 'https://schema.org/InStock',
834
+ url,
835
+ seller: { '@id': ids.organization('shop') },
836
+ },
837
+ aggregateRating: {
838
+ '@type': 'AggregateRating',
839
+ ratingValue: 4.5,
840
+ reviewCount: 42,
841
+ },
842
+ potentialAction: {
843
+ '@type': 'BuyAction',
844
+ target: {
845
+ '@type': 'EntryPoint',
846
+ urlTemplate: 'https://shop.example.com/cart/add/{sku}',
847
+ },
848
+ seller: { '@id': ids.organization('shop') },
849
+ },
850
+ image: productImageUrl,
851
+ }),
852
+ ];
853
+ ```
854
+
855
+ **Product with variants** (sizes, colors — see meta.com for a live example):
856
+
857
+ When a product has multiple variants (e.g. sizes, colors), use `ProductGroup`
858
+ as the parent and individual `Product` entities for each variant:
859
+
860
+ ```ts
861
+ import type { Product, ProductGroup } from 'schema-dts';
862
+
863
+ const variants = [
864
+ {
865
+ sku: 'SHOE-BLK-10',
866
+ name: 'Running Shoe — Black, Size 10',
867
+ color: 'Black',
868
+ size: '10',
869
+ price: 99.99,
870
+ inStock: true,
871
+ },
872
+ {
873
+ sku: 'SHOE-WHT-10',
874
+ name: 'Running Shoe — White, Size 10',
875
+ color: 'White',
876
+ size: '10',
877
+ price: 99.99,
878
+ inStock: true,
879
+ },
880
+ {
881
+ sku: 'SHOE-BLK-11',
882
+ name: 'Running Shoe — Black, Size 11',
883
+ color: 'Black',
884
+ size: '11',
885
+ price: 99.99,
886
+ inStock: false,
887
+ },
888
+ ];
889
+
890
+ const pieces = [
891
+ // ...site-wide + WebPage + BreadcrumbList...
892
+ buildPiece<ProductGroup>({
893
+ '@type': 'ProductGroup',
894
+ '@id': `${url}#product`,
895
+ name: 'Running Shoe',
896
+ description: productDescription,
897
+ brand: 'Nike',
898
+ url,
899
+ productGroupID: 'running-shoe',
900
+ variesBy: ['https://schema.org/color', 'https://schema.org/size'],
901
+ hasVariant: variants.map((v) => ({ '@id': `${url}#product-${v.sku}` })),
902
+ }),
903
+ ...variants.map((v) =>
904
+ buildPiece<Product>({
905
+ '@type': 'Product',
906
+ '@id': `${url}#product-${v.sku}`,
907
+ name: v.name,
908
+ sku: v.sku,
909
+ offers: {
910
+ '@type': 'Offer',
911
+ price: v.price,
912
+ priceCurrency: 'USD',
913
+ availability: v.inStock
914
+ ? 'https://schema.org/InStock'
915
+ : 'https://schema.org/OutOfStock',
916
+ url,
917
+ hasMerchantReturnPolicy: {
918
+ '@type': 'MerchantReturnPolicy',
919
+ merchantReturnDays: 30,
920
+ returnMethod: 'https://schema.org/ReturnByMail',
921
+ returnFees: 'https://schema.org/FreeReturn',
922
+ },
923
+ shippingDetails: {
924
+ '@type': 'OfferShippingDetails',
925
+ shippingRate: { '@type': 'MonetaryAmount', value: 0, currency: 'USD' },
926
+ shippingDestination: { '@type': 'DefinedRegion', addressCountry: 'US' },
927
+ },
928
+ },
929
+ color: v.color,
930
+ size: v.size,
931
+ image: [productImageUrl],
932
+ }),
933
+ ),
934
+ ];
935
+ ```
936
+
937
+ ---
938
+
939
+ ### Local business
940
+
941
+ A restaurant, dentist, shop, or any business with a physical location.
942
+
943
+ ```ts
944
+ import type { Restaurant } from 'schema-dts';
945
+
946
+ const ids = makeIds({ siteUrl: 'https://chezexample.com' });
947
+
948
+ const pieces = [
949
+ buildPiece<Restaurant>({
950
+ '@type': 'Restaurant',
951
+ '@id': ids.organization('chez-example'),
952
+ name: 'Chez Example',
953
+ url: 'https://chezexample.com/',
954
+ logo: logoUrl,
955
+ sameAs: ['https://instagram.com/chezexample'],
956
+ address: {
957
+ '@type': 'PostalAddress',
958
+ streetAddress: '123 Rue de la Paix',
959
+ addressLocality: 'Paris',
960
+ postalCode: '75002',
961
+ addressCountry: 'FR',
962
+ },
963
+ telephone: '+33-1-23-45-67-89',
964
+ priceRange: '$$$',
965
+ servesCuisine: 'French',
966
+ geo: {
967
+ '@type': 'GeoCoordinates',
968
+ latitude: 48.8698,
969
+ longitude: 2.3311,
970
+ },
971
+ openingHoursSpecification: [
972
+ {
973
+ '@type': 'OpeningHoursSpecification',
974
+ dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
975
+ opens: '12:00',
976
+ closes: '14:30',
977
+ },
978
+ {
979
+ '@type': 'OpeningHoursSpecification',
980
+ dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
981
+ opens: '19:00',
982
+ closes: '22:30',
983
+ },
984
+ ],
985
+ }),
986
+ buildWebSite(
987
+ {
988
+ url: siteUrl,
989
+ name: 'Chez Example',
990
+ publisher: { '@id': ids.organization('chez-example') },
991
+ },
992
+ ids,
993
+ ),
994
+ buildWebPage(
995
+ {
996
+ url: siteUrl,
997
+ name: 'Chez Example — French Restaurant in Paris',
998
+ isPartOf: { '@id': ids.website },
999
+ },
1000
+ ids,
1001
+ ),
1002
+ ];
1003
+ ```
1004
+
1005
+ ---
1006
+
1007
+ ### Portfolio / agency
1008
+
1009
+ A freelancer or agency showcasing work.
1010
+
1011
+ ```ts
1012
+ import type { Person } from 'schema-dts';
1013
+
1014
+ const ids = makeIds({ siteUrl: 'https://janedoe.design' });
1015
+
1016
+ // Homepage — CollectionPage showcasing work
1017
+ const pieces = [
1018
+ buildPiece<Person>({
1019
+ '@type': 'Person',
1020
+ '@id': ids.person,
1021
+ name: 'Jane Doe',
1022
+ jobTitle: 'Product Designer',
1023
+ url: siteUrl,
1024
+ image: { '@id': ids.personImage },
1025
+ sameAs: [dribbble, linkedin],
1026
+ }),
1027
+ buildImageObject({ id: ids.personImage, url: headshot, width: 400, height: 400 }, ids),
1028
+ buildWebSite({ url: siteUrl, name: 'Jane Doe Design', publisher: { '@id': ids.person } }, ids),
1029
+ buildWebPage(
1030
+ {
1031
+ url: siteUrl,
1032
+ name: 'Jane Doe — Product Designer',
1033
+ isPartOf: { '@id': ids.website },
1034
+ about: { '@id': ids.person },
1035
+ },
1036
+ ids,
1037
+ 'CollectionPage',
1038
+ ),
1039
+ ];
1040
+
1041
+ // Individual project page
1042
+ const projectPieces = [
1043
+ // ...site-wide entities...
1044
+ buildWebPage(
1045
+ {
1046
+ url,
1047
+ name: projectTitle,
1048
+ isPartOf: { '@id': ids.website },
1049
+ breadcrumb: { '@id': ids.breadcrumb(url) },
1050
+ datePublished,
1051
+ },
1052
+ ids,
1053
+ ),
1054
+ buildArticle(
1055
+ {
1056
+ url,
1057
+ isPartOf: { '@id': ids.webPage(url) },
1058
+ author: { '@id': ids.person },
1059
+ publisher: { '@id': ids.person },
1060
+ headline: projectTitle,
1061
+ description,
1062
+ datePublished,
1063
+ },
1064
+ ids,
1065
+ ),
1066
+ buildBreadcrumbList(
1067
+ {
1068
+ url,
1069
+ items: [
1070
+ { name: 'Home', url: siteUrl },
1071
+ { name: 'Work', url: workUrl },
1072
+ { name: projectTitle, url },
1073
+ ],
1074
+ },
1075
+ ids,
1076
+ ),
1077
+ ];
1078
+ ```
1079
+
1080
+ ---
1081
+
1082
+ ### Documentation site
1083
+
1084
+ A docs site for a software project or API.
1085
+
1086
+ ```ts
1087
+ import type { Organization } from 'schema-dts';
1088
+
1089
+ const ids = makeIds({ siteUrl: 'https://docs.example.com' });
1090
+
1091
+ const pieces = [
1092
+ buildPiece<Organization>({
1093
+ '@type': 'Organization',
1094
+ '@id': ids.organization('example'),
1095
+ name: 'Example Inc',
1096
+ url: 'https://example.com/',
1097
+ logo: logoUrl,
1098
+ }),
1099
+ buildWebSite(
1100
+ {
1101
+ url: siteUrl,
1102
+ name: 'Example Docs',
1103
+ publisher: { '@id': ids.organization('example') },
1104
+ description: 'Documentation for Example SDK',
1105
+ },
1106
+ ids,
1107
+ ),
1108
+ buildWebPage(
1109
+ {
1110
+ url,
1111
+ name: pageTitle,
1112
+ isPartOf: { '@id': ids.website },
1113
+ breadcrumb: { '@id': ids.breadcrumb(url) },
1114
+ dateModified,
1115
+ },
1116
+ ids,
1117
+ ),
1118
+ buildBreadcrumbList(
1119
+ {
1120
+ url,
1121
+ items: [
1122
+ { name: 'Docs', url: siteUrl },
1123
+ { name: 'Guides', url: guidesUrl },
1124
+ { name: pageTitle, url },
1125
+ ],
1126
+ },
1127
+ ids,
1128
+ ),
1129
+ ];
1130
+ ```
1131
+
1132
+ For docs, `Article` is optional. Many documentation pages are better served by
1133
+ just `WebPage` + `BreadcrumbList`. Add `Article` only for tutorial-style content
1134
+ with a clear author and publish date.
1135
+
1136
+ ---
1137
+
1138
+ ### Podcast / video site
1139
+
1140
+ Just as `Blog` is a container for `BlogPosting`, `PodcastSeries` is a
1141
+ container for `PodcastEpisode`. Include the series as a site-wide entity.
1142
+
1143
+ **Video podcast (YouTube-based):**
1144
+
1145
+ ```ts
1146
+ import type { Person, PodcastSeries } from 'schema-dts';
1147
+
1148
+ const ids = makeIds({ siteUrl: 'https://podcast.example.com' });
1149
+ const seriesId = `${siteUrl}#podcast-series`;
1150
+
1151
+ // Episode page
1152
+ const pieces = [
1153
+ buildPiece<Person>({
1154
+ '@type': 'Person',
1155
+ '@id': ids.person,
1156
+ name: 'Host Name',
1157
+ url: aboutUrl,
1158
+ image: { '@id': ids.personImage },
1159
+ }),
1160
+ buildImageObject({ id: ids.personImage, url: hostPhotoUrl, width: 400, height: 400 }, ids),
1161
+ buildWebSite({ url: siteUrl, name: 'My Podcast', publisher: { '@id': ids.person } }, ids),
1162
+ buildPiece<PodcastSeries>({
1163
+ '@type': 'PodcastSeries',
1164
+ '@id': seriesId,
1165
+ name: 'My Podcast',
1166
+ description: 'A weekly show about...',
1167
+ url: siteUrl,
1168
+ author: { '@id': ids.person },
1169
+ publisher: { '@id': ids.person },
1170
+ inLanguage: 'en-US',
1171
+ webFeed: `${siteUrl}feed.xml`,
1172
+ }),
1173
+ buildWebPage(
1174
+ {
1175
+ url,
1176
+ name: episodeTitle,
1177
+ isPartOf: { '@id': ids.website },
1178
+ breadcrumb: { '@id': ids.breadcrumb(url) },
1179
+ datePublished,
1180
+ },
1181
+ ids,
1182
+ ),
1183
+ buildVideoObject(
1184
+ {
1185
+ url,
1186
+ name: episodeTitle,
1187
+ description: episodeDescription,
1188
+ isPartOf: { '@id': ids.webPage(url) },
1189
+ youtubeId,
1190
+ uploadDate: publishDate,
1191
+ duration: 'PT45M',
1192
+ transcript,
1193
+ },
1194
+ ids,
1195
+ ),
1196
+ buildBreadcrumbList(
1197
+ {
1198
+ url,
1199
+ items: [
1200
+ { name: 'Home', url: siteUrl },
1201
+ { name: 'Episodes', url: episodesUrl },
1202
+ { name: episodeTitle, url },
1203
+ ],
1204
+ },
1205
+ ids,
1206
+ ),
1207
+ ];
1208
+ ```
1209
+
1210
+ **Audio-only podcast:**
1211
+
1212
+ Use `PodcastEpisode` linked to the `PodcastSeries`:
1213
+
1214
+ ```ts
1215
+ import type { PodcastEpisode } from 'schema-dts';
1216
+
1217
+ const seriesId = `${siteUrl}#podcast-series`;
1218
+
1219
+ const pieces = [
1220
+ // ...site-wide entities including PodcastSeries...
1221
+ buildWebPage({ url, name: episodeTitle, isPartOf: { '@id': ids.website }, datePublished }, ids),
1222
+ buildPiece<PodcastEpisode>({
1223
+ '@type': 'PodcastEpisode',
1224
+ '@id': `${url}#episode`,
1225
+ name: episodeTitle,
1226
+ description: episodeDescription,
1227
+ url,
1228
+ datePublished: publishDate.toISOString(),
1229
+ duration: 'PT45M',
1230
+ episodeNumber: 42,
1231
+ partOfSeries: { '@id': seriesId },
1232
+ associatedMedia: {
1233
+ '@type': 'MediaObject',
1234
+ contentUrl: mp3Url,
1235
+ encodingFormat: 'audio/mpeg',
1236
+ duration: 'PT45M',
1237
+ },
1238
+ author: { '@id': ids.person },
1239
+ }),
1240
+ buildBreadcrumbList(
1241
+ {
1242
+ url,
1243
+ items: [
1244
+ { name: 'Home', url: siteUrl },
1245
+ { name: 'Episodes', url: episodesUrl },
1246
+ { name: episodeTitle, url },
1247
+ ],
1248
+ },
1249
+ ids,
1250
+ ),
1251
+ ];
1252
+ ```
1253
+
1254
+ **Podcast listing page** (`/episodes/`):
1255
+
1256
+ ```ts
1257
+ const pieces = [
1258
+ // ...site-wide entities...
1259
+ buildWebPage(
1260
+ { url, name: 'Episodes', isPartOf: { '@id': ids.website }, about: { '@id': seriesId } },
1261
+ ids,
1262
+ 'CollectionPage',
1263
+ ),
1264
+ ];
1265
+ ```
1266
+
1267
+ ---
1268
+
1269
+ ### Vacation rental / accommodation
1270
+
1271
+ ```ts
1272
+ import type { Person, VacationRental } from 'schema-dts';
1273
+
1274
+ const ids = makeIds({ siteUrl: 'https://myhouse.example.com' });
1275
+
1276
+ const pieces = [
1277
+ buildPiece<Person>({ '@type': 'Person', '@id': ids.person, name: 'Owner Name', url: siteUrl }),
1278
+ buildWebSite({ url: siteUrl, name: 'Villa Example', publisher: { '@id': ids.person } }, ids),
1279
+ buildWebPage(
1280
+ {
1281
+ url: siteUrl,
1282
+ name: 'Villa Example — Holiday Home in Tuscany',
1283
+ isPartOf: { '@id': ids.website },
1284
+ },
1285
+ ids,
1286
+ ),
1287
+ buildPiece<VacationRental>({
1288
+ '@type': 'VacationRental',
1289
+ '@id': `${siteUrl}#rental`,
1290
+ name: 'Villa Example',
1291
+ description: 'A beautiful villa...',
1292
+ url: siteUrl,
1293
+ image: [heroImageUrl],
1294
+ address: {
1295
+ '@type': 'PostalAddress',
1296
+ addressLocality: 'Lucca',
1297
+ addressRegion: 'Tuscany',
1298
+ addressCountry: 'IT',
1299
+ },
1300
+ geo: {
1301
+ '@type': 'GeoCoordinates',
1302
+ latitude: 43.84,
1303
+ longitude: 10.5,
1304
+ },
1305
+ numberOfRooms: 4,
1306
+ occupancy: {
1307
+ '@type': 'QuantitativeValue',
1308
+ maxValue: 8,
1309
+ },
1310
+ amenityFeature: [
1311
+ { '@type': 'LocationFeatureSpecification', name: 'Pool', value: true },
1312
+ { '@type': 'LocationFeatureSpecification', name: 'WiFi', value: true },
1313
+ ],
1314
+ potentialAction: {
1315
+ '@type': 'RentAction',
1316
+ target: {
1317
+ '@type': 'EntryPoint',
1318
+ urlTemplate:
1319
+ 'https://myhouse.example.com/book?checkin={checkin}&checkout={checkout}&guests={guests}',
1320
+ },
1321
+ landlord: { '@id': ids.person },
1322
+ priceSpecification: {
1323
+ '@type': 'UnitPriceSpecification',
1324
+ price: 250,
1325
+ priceCurrency: 'EUR',
1326
+ unitCode: 'DAY',
1327
+ },
1328
+ },
1329
+ }),
1330
+ ];
1331
+ ```
1332
+
1333
+ ---
1334
+
1335
+ ### Recipe site
1336
+
1337
+ ```ts
1338
+ import type { Recipe } from 'schema-dts';
1339
+
1340
+ const ids = makeIds({ siteUrl: 'https://recipes.example.com' });
1341
+
1342
+ const pieces = [
1343
+ // ...site-wide entities...
1344
+ buildWebPage(
1345
+ {
1346
+ url,
1347
+ name: recipeName,
1348
+ isPartOf: { '@id': ids.website },
1349
+ breadcrumb: { '@id': ids.breadcrumb(url) },
1350
+ datePublished,
1351
+ },
1352
+ ids,
1353
+ ),
1354
+ buildBreadcrumbList(
1355
+ {
1356
+ url,
1357
+ items: [
1358
+ { name: 'Home', url: siteUrl },
1359
+ { name: 'Italian', url: categoryUrl },
1360
+ { name: recipeName, url },
1361
+ ],
1362
+ },
1363
+ ids,
1364
+ ),
1365
+ buildPiece<Recipe>({
1366
+ '@type': 'Recipe',
1367
+ '@id': `${url}#recipe`,
1368
+ name: recipeName,
1369
+ author: { '@id': ids.person },
1370
+ prepTime: 'PT15M',
1371
+ cookTime: 'PT45M',
1372
+ totalTime: 'PT1H',
1373
+ recipeYield: '4 servings',
1374
+ recipeCategory: 'Main course',
1375
+ recipeCuisine: 'Italian',
1376
+ nutrition: {
1377
+ '@type': 'NutritionInformation',
1378
+ calories: '450 calories',
1379
+ },
1380
+ recipeIngredient: [
1381
+ '400g spaghetti',
1382
+ '200g guanciale',
1383
+ '4 egg yolks',
1384
+ '100g pecorino romano',
1385
+ ],
1386
+ recipeInstructions: [
1387
+ { '@type': 'HowToStep', text: 'Boil the spaghetti in salted water.' },
1388
+ { '@type': 'HowToStep', text: 'Fry the guanciale until crispy.' },
1389
+ { '@type': 'HowToStep', text: 'Mix egg yolks with pecorino.' },
1390
+ { '@type': 'HowToStep', text: 'Combine and serve immediately.' },
1391
+ ],
1392
+ description: recipeDescription,
1393
+ image: recipeImageUrl,
1394
+ datePublished: publishDate.toISOString(),
1395
+ }),
1396
+ ];
1397
+ ```
1398
+
1399
+ ---
1400
+
1401
+ ### Event page
1402
+
1403
+ ```ts
1404
+ import type { Event } from 'schema-dts';
1405
+
1406
+ buildPiece<Event>({
1407
+ '@type': 'Event',
1408
+ '@id': `${url}#event`,
1409
+ name: 'JavaScript Conference 2026',
1410
+ description: 'Annual JavaScript conference...',
1411
+ startDate: '2026-09-15T09:00:00+02:00',
1412
+ endDate: '2026-09-17T18:00:00+02:00',
1413
+ eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
1414
+ eventStatus: 'https://schema.org/EventScheduled',
1415
+ location: {
1416
+ '@type': 'Place',
1417
+ name: 'Congress Center',
1418
+ address: {
1419
+ '@type': 'PostalAddress',
1420
+ addressLocality: 'Amsterdam',
1421
+ addressCountry: 'NL',
1422
+ },
1423
+ },
1424
+ organizer: { '@id': ids.organization('organizer-slug') },
1425
+ offers: {
1426
+ '@type': 'Offer',
1427
+ price: 299,
1428
+ priceCurrency: 'EUR',
1429
+ availability: 'https://schema.org/InStock',
1430
+ url: ticketUrl,
1431
+ validFrom: '2026-01-01T00:00:00+01:00',
1432
+ },
1433
+ image: eventImageUrl,
1434
+ }),
1435
+ ```
1436
+
1437
+ ---
1438
+
1439
+ ### SaaS / software product landing page
1440
+
1441
+ ```ts
1442
+ import type { Organization, SoftwareApplication } from 'schema-dts';
1443
+
1444
+ const pieces = [
1445
+ buildPiece<Organization>({
1446
+ '@type': 'Organization',
1447
+ '@id': ids.organization('myapp'),
1448
+ name: 'MyApp Inc',
1449
+ url: siteUrl,
1450
+ logo: logoUrl,
1451
+ }),
1452
+ buildWebSite(
1453
+ { url: siteUrl, name: 'MyApp', publisher: { '@id': ids.organization('myapp') } },
1454
+ ids,
1455
+ ),
1456
+ buildWebPage(
1457
+ {
1458
+ url: siteUrl,
1459
+ name: 'MyApp — Project Management for Teams',
1460
+ isPartOf: { '@id': ids.website },
1461
+ },
1462
+ ids,
1463
+ ),
1464
+ buildPiece<SoftwareApplication>({
1465
+ '@type': 'SoftwareApplication',
1466
+ '@id': `${siteUrl}#app`,
1467
+ name: 'MyApp',
1468
+ description: 'Project management for distributed teams.',
1469
+ url: siteUrl,
1470
+ applicationCategory: 'BusinessApplication',
1471
+ operatingSystem: 'Web',
1472
+ offers: {
1473
+ '@type': 'Offer',
1474
+ price: 0,
1475
+ priceCurrency: 'USD',
1476
+ description: 'Free tier available',
1477
+ },
1478
+ aggregateRating: {
1479
+ '@type': 'AggregateRating',
1480
+ ratingValue: 4.7,
1481
+ ratingCount: 1200,
1482
+ },
1483
+ potentialAction: {
1484
+ '@type': 'BuyAction',
1485
+ target: {
1486
+ '@type': 'EntryPoint',
1487
+ url: `${siteUrl}signup/`,
1488
+ },
1489
+ price: 0,
1490
+ priceCurrency: 'USD',
1491
+ description: 'Start free trial',
1492
+ },
1493
+ }),
1494
+ ];
1495
+ ```
1496
+
1497
+ ---
1498
+
1499
+ ### FAQ page
1500
+
1501
+ Combine `WebPage` with a `FAQPage` custom piece:
1502
+
1503
+ ```ts
1504
+ import type { FAQPage } from 'schema-dts';
1505
+
1506
+ const pieces = [
1507
+ // ...site-wide entities...
1508
+ buildWebPage(
1509
+ { url, name: 'Frequently Asked Questions', isPartOf: { '@id': ids.website } },
1510
+ ids,
1511
+ ),
1512
+ buildPiece<FAQPage>({
1513
+ '@type': 'FAQPage',
1514
+ '@id': `${url}#faq`,
1515
+ mainEntity: [
1516
+ {
1517
+ '@type': 'Question',
1518
+ name: 'How do I install seo-graph?',
1519
+ acceptedAnswer: {
1520
+ '@type': 'Answer',
1521
+ text: 'Run npm install @jdevalk/seo-graph-core',
1522
+ },
1523
+ },
1524
+ {
1525
+ '@type': 'Question',
1526
+ name: 'Does it work with Next.js?',
1527
+ acceptedAnswer: {
1528
+ '@type': 'Answer',
1529
+ text: 'Yes. Use @jdevalk/seo-graph-core directly. The Astro integration is Astro-only.',
1530
+ },
1531
+ },
1532
+ ],
1533
+ }),
1534
+ ];
1535
+ ```
1536
+
1537
+ ---
1538
+
1539
+ ### Course / educational content
1540
+
1541
+ ```ts
1542
+ import type { Course } from 'schema-dts';
1543
+
1544
+ buildPiece<Course>({
1545
+ '@type': 'Course',
1546
+ '@id': `${url}#course`,
1547
+ name: 'Introduction to TypeScript',
1548
+ description: 'Learn TypeScript from scratch...',
1549
+ provider: { '@id': ids.organization('school-slug') },
1550
+ instructor: { '@id': ids.person },
1551
+ courseCode: 'TS-101',
1552
+ hasCourseInstance: {
1553
+ '@type': 'CourseInstance',
1554
+ courseMode: 'online',
1555
+ startDate: '2026-06-01',
1556
+ endDate: '2026-08-01',
1557
+ },
1558
+ offers: {
1559
+ '@type': 'Offer',
1560
+ price: 49,
1561
+ priceCurrency: 'USD',
1562
+ },
1563
+ }),
1564
+ ```
1565
+
1566
+ ---
1567
+
1568
+ ### News / magazine site
1569
+
1570
+ Same as a company blog, but consider using `NewsArticle` instead of `Article`:
1571
+
1572
+ ```ts
1573
+ buildArticle({
1574
+ url,
1575
+ headline: title,
1576
+ description: excerpt,
1577
+ datePublished: publishDate,
1578
+ dateModified: modifiedDate,
1579
+ author: { '@id': authorPersonId },
1580
+ publisher: { '@id': ids.organization('newsroom') },
1581
+ isPartOf: { '@id': ids.webPage(url) },
1582
+ articleSection: section,
1583
+ image: { '@id': ids.primaryImage(url) },
1584
+ }, ids, 'NewsArticle'),
1585
+ ```
1586
+
1587
+ ---
1588
+
1589
+ ## Trust and credibility signals
1590
+
1591
+ ### publishingPrinciples
1592
+
1593
+ The `publishingPrinciples` property links to a document describing editorial
1594
+ policies. It can be applied to `Organization`, `Person`, or `CreativeWork`
1595
+ (including `Blog`). This is one of the strongest trust signals you can give
1596
+ search engines and AI agents about your content's credibility.
1597
+
1598
+ ```ts
1599
+ import type { Person, Blog, Organization } from 'schema-dts';
1600
+
1601
+ // On a Person entity (personal blog)
1602
+ buildPiece<Person>({
1603
+ '@type': 'Person',
1604
+ '@id': ids.person,
1605
+ name: 'Jane Doe',
1606
+ url: aboutUrl,
1607
+ publishingPrinciples: `${siteUrl}/editorial-policy/`,
1608
+ }),
1609
+
1610
+ // On a Blog entity
1611
+ buildPiece<Blog>({
1612
+ '@type': 'Blog',
1613
+ '@id': blogId,
1614
+ name: 'My Blog',
1615
+ url: `${siteUrl}/blog/`,
1616
+ publisher: { '@id': ids.person },
1617
+ publishingPrinciples: `${siteUrl}/editorial-policy/`,
1618
+ }),
1619
+
1620
+ // On an Organization (news site, company blog)
1621
+ buildPiece<Organization>({
1622
+ '@type': 'Organization',
1623
+ '@id': ids.organization('newsroom'),
1624
+ name: 'The Daily Example',
1625
+ publishingPrinciples: `${siteUrl}/ethics/`,
1626
+ }),
1627
+ ```
1628
+
1629
+ ### Specialized policy sub-properties
1630
+
1631
+ For news and media organizations, schema.org has more specific sub-properties
1632
+ of `publishingPrinciples`:
1633
+
1634
+ ```ts
1635
+ import type { Organization } from 'schema-dts';
1636
+
1637
+ buildPiece<Organization>({
1638
+ '@type': 'Organization',
1639
+ '@id': ids.organization('newsroom'),
1640
+ name: 'The Daily Example',
1641
+ url: siteUrl,
1642
+ publishingPrinciples: `${siteUrl}/editorial-policy/`,
1643
+ correctionsPolicy: `${siteUrl}/corrections/`,
1644
+ verificationFactCheckingPolicy: `${siteUrl}/fact-checking/`,
1645
+ actionableFeedbackPolicy: `${siteUrl}/feedback/`,
1646
+ unnamedSourcesPolicy: `${siteUrl}/sources-policy/`,
1647
+ ownershipFundingInfo: `${siteUrl}/about/ownership/`,
1648
+ diversityStaffingReport: `${siteUrl}/diversity-report/`,
1649
+ masthead: `${siteUrl}/team/`,
1650
+ }),
1651
+ ```
1652
+
1653
+ ### When to use which
1654
+
1655
+ | Site type | Recommended properties |
1656
+ | ---------------------------------- | ----------------------------------------------------------------------------- |
1657
+ | Personal blog | `publishingPrinciples` on Person or Blog |
1658
+ | Company blog | `publishingPrinciples` on Organization |
1659
+ | News / magazine | All sub-properties (corrections, fact-checking, sources, ownership, masthead) |
1660
+ | Documentation site | `publishingPrinciples` on Organization (link to contribution guidelines) |
1661
+ | Any site with AI-generated content | `publishingPrinciples` (link to AI usage disclosure) |
1662
+
1663
+ **Practical advice:** You don't need all of these. Start with
1664
+ `publishingPrinciples` on your primary entity (Person or Organization). Add
1665
+ the sub-properties if you actually have those policy pages. Don't create empty
1666
+ policy pages just to fill the properties.
1667
+
1668
+ ### Copyright, licensing, and access
1669
+
1670
+ `WebPage` (and all `CreativeWork` types, including `Article`, `BlogPosting`,
1671
+ `Blog`, and `Product`) supports copyright and licensing properties. These are
1672
+ increasingly important as AI agents need to understand what they can and can't
1673
+ do with your content.
1674
+
1675
+ **On WebPage:**
1676
+
1677
+ ```ts
1678
+ buildWebPage({
1679
+ url,
1680
+ name: title,
1681
+ isPartOf: { '@id': ids.website },
1682
+ datePublished,
1683
+ copyrightHolder: { '@id': ids.person },
1684
+ copyrightYear: 2026,
1685
+ copyrightNotice: '© 2026 Jane Doe. All rights reserved.',
1686
+ license: 'https://creativecommons.org/licenses/by/4.0/',
1687
+ isAccessibleForFree: true,
1688
+ creditText: 'Jane Doe / janedoe.com',
1689
+ }, ids),
1690
+ ```
1691
+
1692
+ **On Article or BlogPosting:**
1693
+
1694
+ ```ts
1695
+ buildArticle({
1696
+ url,
1697
+ headline: title,
1698
+ // ...other article properties...
1699
+ copyrightHolder: { '@id': ids.person },
1700
+ copyrightYear: 2026,
1701
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/',
1702
+ }, ids, 'BlogPosting'),
1703
+ ```
1704
+
1705
+ **On WebSite (site-wide default):**
1706
+
1707
+ ```ts
1708
+ buildWebSite({
1709
+ url: siteUrl,
1710
+ name: 'My Site',
1711
+ publisher: { '@id': ids.person },
1712
+ copyrightHolder: { '@id': ids.person },
1713
+ license: 'https://creativecommons.org/licenses/by/4.0/',
1714
+ }, ids),
1715
+ ```
1716
+
1717
+ ### Copyright and licensing properties reference
1718
+
1719
+ | Property | Type | Use for |
1720
+ | --------------------- | ---------------------- | --------------------------------------------------- |
1721
+ | `copyrightHolder` | Person or Organization | Who holds the copyright |
1722
+ | `copyrightYear` | Number | Year copyright was first asserted |
1723
+ | `copyrightNotice` | Text | Human-readable copyright text |
1724
+ | `license` | URL or CreativeWork | License that applies (CC, MIT, custom) |
1725
+ | `acquireLicensePage` | URL | Where to buy/request a license for reuse |
1726
+ | `creditText` | Text | How to credit when reusing (e.g. "Photo: Jane Doe") |
1727
+ | `isAccessibleForFree` | Boolean | Whether the content is free to access |
1728
+ | `conditionsOfAccess` | Text | Access conditions in natural language |
1729
+
1730
+ ### When to use what
1731
+
1732
+ | Scenario | Properties to include |
1733
+ | ----------------------------------- | -------------------------------------------------------------------------------- |
1734
+ | Personal blog (all rights reserved) | `copyrightHolder`, `copyrightYear` |
1735
+ | Blog with Creative Commons license | `copyrightHolder`, `copyrightYear`, `license` |
1736
+ | Paywalled content | `isAccessibleForFree: false`, `conditionsOfAccess: 'Requires paid subscription'` |
1737
+ | Stock photography site | `copyrightHolder`, `license`, `acquireLicensePage`, `creditText` |
1738
+ | Open source docs (MIT/Apache) | `license` pointing to the license URL |
1739
+ | News with free + premium tiers | `isAccessibleForFree` per-article (true for free, false for premium) |
1740
+ | AI training opt-out signal | `copyrightNotice` + `license` with restrictive terms |
1741
+
1742
+ **Note on AI and licensing:** While `license` and `copyrightNotice` don't
1743
+ legally prevent AI training (that's what robots.txt, TDM headers, and
1744
+ contracts are for), they give agents clear metadata about your content's
1745
+ terms. An agent that respects licensing can check these properties before
1746
+ deciding how to use your content.
1747
+
1748
+ ---
1749
+
1750
+ ## Choosing the right Article subtype
1751
+
1752
+ `buildArticle` defaults to `@type: Article`, which is correct for most content.
1753
+ Pass a subtype as the third argument for more precise semantics:
1754
+
1755
+ | Type | When to use | Example |
1756
+ | ------------------ | --------------------------------------------------------- | ------------------------------- |
1757
+ | `Article` | Default. General articles, tutorials, guides. | "How to set up ESLint" |
1758
+ | `BlogPosting` | Personal blog posts, opinion pieces, diary-style entries. | "Why I switched to Astro" |
1759
+ | `NewsArticle` | News reporting, journalism, press releases. | "Google announces new protocol" |
1760
+ | `TechArticle` | Technical documentation, API guides, spec write-ups. | "WebSocket protocol deep dive" |
1761
+ | `ScholarlyArticle` | Academic papers, research publications. | "Effects of caching on TTFB" |
1762
+ | `Report` | Data reports, annual reviews, research findings. | "State of CSS 2026" |
1763
+
1764
+ ```ts
1765
+ buildArticle(
1766
+ {
1767
+ url,
1768
+ headline: title,
1769
+ description: excerpt,
1770
+ datePublished: publishDate,
1771
+ dateModified: modifiedDate,
1772
+ author: { '@id': ids.person },
1773
+ publisher: { '@id': ids.person },
1774
+ isPartOf: { '@id': ids.webPage(url) },
1775
+ image: { '@id': ids.primaryImage(url) },
1776
+ articleSection: category,
1777
+ wordCount,
1778
+ articleBody: plainTextBody,
1779
+ },
1780
+ ids,
1781
+ 'BlogPosting',
1782
+ );
1783
+ ```
1784
+
1785
+ jonoalderson.com uses `BlogPosting` for all blog content. Most SEO plugins
1786
+ default to `Article`. Both are valid; `BlogPosting` is more semantically
1787
+ precise for personal blogs.
1788
+
1789
+ ---
1790
+
1791
+ ## Actions: telling agents what they can do
1792
+
1793
+ The `potentialAction` property on any entity tells search engines and AI agents
1794
+ _what actions can be performed_ and _where to go to perform them_. This is the
1795
+ mechanism that makes your schema truly agent-ready: an agent can read your
1796
+ graph, find a `BuyAction` on a Product, and navigate to the checkout URL.
1797
+
1798
+ ### The TradeAction family
1799
+
1800
+ All commerce-related actions inherit from `TradeAction`:
1801
+
1802
+ | Action | Use for | Key extra property |
1803
+ | ---------------- | -------------------------------------- | ----------------------------- |
1804
+ | `BuyAction` | Direct purchase (add to cart, buy now) | `seller` |
1805
+ | `OrderAction` | Order for delivery | `deliveryMethod` |
1806
+ | `PreOrderAction` | Not yet available, reserve now | — |
1807
+ | `RentAction` | Vacation rentals, equipment, cars | `landlord`, `realEstateAgent` |
1808
+ | `QuoteAction` | Custom pricing, request a quote | — |
1809
+ | `SellAction` | Marketplace listings (seller-side) | `buyer` |
1810
+ | `PayAction` | Payment processing | — |
1811
+ | `TipAction` | Donations, tips, support | — |
1812
+
1813
+ ### The pattern
1814
+
1815
+ Every action uses `target` with an `EntryPoint` to specify the URL where the
1816
+ action can be performed. The `urlTemplate` variant supports parameters:
1817
+
1818
+ ```ts
1819
+ potentialAction: {
1820
+ '@type': 'BuyAction',
1821
+ target: {
1822
+ '@type': 'EntryPoint',
1823
+ urlTemplate: 'https://shop.example.com/cart/add/{sku}',
1824
+ // or just: url: 'https://shop.example.com/cart/add/ABC123',
1825
+ },
1826
+ }
1827
+ ```
1828
+
1829
+ ### Buying a product
1830
+
1831
+ Add to the `Product` or `ProductGroup` entity:
1832
+
1833
+ ```ts
1834
+ import type { Product } from 'schema-dts';
1835
+
1836
+ buildPiece<Product>({
1837
+ '@type': 'Product',
1838
+ '@id': `${url}#product`,
1839
+ name: productName,
1840
+ // ...other product properties...
1841
+ potentialAction: {
1842
+ '@type': 'BuyAction',
1843
+ target: {
1844
+ '@type': 'EntryPoint',
1845
+ urlTemplate: `https://shop.example.com/cart/add/{sku}`,
1846
+ },
1847
+ seller: { '@id': ids.organization('shop') },
1848
+ },
1849
+ }),
1850
+ ```
1851
+
1852
+ ### Pre-ordering a product
1853
+
1854
+ For products not yet available:
1855
+
1856
+ ```ts
1857
+ potentialAction: {
1858
+ '@type': 'PreOrderAction',
1859
+ target: {
1860
+ '@type': 'EntryPoint',
1861
+ url: 'https://shop.example.com/pre-order/new-gadget',
1862
+ },
1863
+ description: 'Pre-order — ships March 2027',
1864
+ },
1865
+ ```
1866
+
1867
+ ### Ordering with delivery
1868
+
1869
+ When you need to specify how the product will be delivered:
1870
+
1871
+ ```ts
1872
+ potentialAction: {
1873
+ '@type': 'OrderAction',
1874
+ target: {
1875
+ '@type': 'EntryPoint',
1876
+ url: `${url}checkout/`,
1877
+ },
1878
+ deliveryMethod: 'https://schema.org/ParcelService',
1879
+ },
1880
+ ```
1881
+
1882
+ `deliveryMethod` values: `ParcelService`, `OnSitePickup`, `LockerDelivery`.
1883
+
1884
+ ### Renting (vacation rental, equipment, cars)
1885
+
1886
+ Add to the `VacationRental`, `Product`, or `Car` entity:
1887
+
1888
+ ```ts
1889
+ import type { VacationRental } from 'schema-dts';
1890
+
1891
+ buildPiece<VacationRental>({
1892
+ '@type': 'VacationRental',
1893
+ '@id': `${siteUrl}#rental`,
1894
+ name: 'Villa Example',
1895
+ // ...other rental properties...
1896
+ potentialAction: {
1897
+ '@type': 'RentAction',
1898
+ target: {
1899
+ '@type': 'EntryPoint',
1900
+ urlTemplate: 'https://myhouse.example.com/book?checkin={checkin}&checkout={checkout}',
1901
+ },
1902
+ landlord: { '@id': ids.person },
1903
+ priceSpecification: {
1904
+ '@type': 'UnitPriceSpecification',
1905
+ price: 250,
1906
+ priceCurrency: 'EUR',
1907
+ unitCode: 'DAY',
1908
+ },
1909
+ },
1910
+ }),
1911
+ ```
1912
+
1913
+ **URL template variables for rentals:** `{checkin}`, `{checkout}`, `{guests}`
1914
+ are conventional but not standardized. Use names that match your booking form's
1915
+ query parameters.
1916
+
1917
+ For rentals through an agency:
1918
+
1919
+ ```ts
1920
+ potentialAction: {
1921
+ '@type': 'RentAction',
1922
+ target: {
1923
+ '@type': 'EntryPoint',
1924
+ url: 'https://bookingagency.com/listing/villa-example',
1925
+ },
1926
+ landlord: { '@id': ids.person },
1927
+ realEstateAgent: {
1928
+ '@type': 'RealEstateAgent',
1929
+ name: 'Tuscany Villas Agency',
1930
+ url: 'https://bookingagency.com/',
1931
+ },
1932
+ },
1933
+ ```
1934
+
1935
+ ### Requesting a quote
1936
+
1937
+ For services or products with custom pricing (B2B, consulting, configured
1938
+ products):
1939
+
1940
+ ```ts
1941
+ potentialAction: {
1942
+ '@type': 'QuoteAction',
1943
+ target: {
1944
+ '@type': 'EntryPoint',
1945
+ url: 'https://agency.example.com/contact',
1946
+ },
1947
+ description: 'Request a project quote',
1948
+ },
1949
+ ```
1950
+
1951
+ ### Marketplace listings
1952
+
1953
+ Marketplaces often need both buy and make-offer actions:
1954
+
1955
+ ```ts
1956
+ potentialAction: [
1957
+ {
1958
+ '@type': 'BuyAction',
1959
+ target: {
1960
+ '@type': 'EntryPoint',
1961
+ url: buyNowUrl,
1962
+ },
1963
+ seller: { '@id': sellerPersonId },
1964
+ price: 499,
1965
+ priceCurrency: 'USD',
1966
+ },
1967
+ {
1968
+ '@type': 'QuoteAction',
1969
+ target: {
1970
+ '@type': 'EntryPoint',
1971
+ url: makeOfferUrl,
1972
+ },
1973
+ description: 'Make an offer',
1974
+ },
1975
+ ],
1976
+ ```
1977
+
1978
+ ### Donations and tips
1979
+
1980
+ For open source projects, creators, or nonprofits:
1981
+
1982
+ ```ts
1983
+ potentialAction: {
1984
+ '@type': 'TipAction',
1985
+ target: {
1986
+ '@type': 'EntryPoint',
1987
+ url: 'https://example.com/donate',
1988
+ },
1989
+ description: 'Support this project',
1990
+ recipient: { '@id': ids.person },
1991
+ },
1992
+ ```
1993
+
1994
+ ### Combining actions with SearchAction
1995
+
1996
+ Many entities benefit from multiple actions. A WebSite typically has a
1997
+ `SearchAction`; the entities within it have trade actions:
1998
+
1999
+ ```ts
2000
+ import type { Product } from 'schema-dts';
2001
+
2002
+ // WebSite: how to search
2003
+ buildWebSite({
2004
+ url: siteUrl,
2005
+ name: 'My Shop',
2006
+ publisher: { '@id': ids.organization('shop') },
2007
+ potentialAction: {
2008
+ '@type': 'SearchAction',
2009
+ target: {
2010
+ '@type': 'EntryPoint',
2011
+ urlTemplate: `${siteUrl}search?q={search_term_string}`,
2012
+ },
2013
+ 'query-input': {
2014
+ '@type': 'PropertyValueSpecification',
2015
+ valueRequired: true,
2016
+ valueName: 'search_term_string',
2017
+ },
2018
+ },
2019
+ }, ids),
2020
+
2021
+ // Product: how to buy
2022
+ buildPiece<Product>({
2023
+ '@type': 'Product',
2024
+ '@id': `${url}#product`,
2025
+ name: productName,
2026
+ potentialAction: {
2027
+ '@type': 'BuyAction',
2028
+ target: { '@type': 'EntryPoint', url: addToCartUrl },
2029
+ seller: { '@id': ids.organization('shop') },
2030
+ },
2031
+ }),
2032
+ ```
2033
+
2034
+ ### When to use which action
2035
+
2036
+ | Scenario | Action | Why |
2037
+ | ------------------------------- | ------------------------------------------------------- | ------------------------------- |
2038
+ | E-commerce product, buy now | `BuyAction` | Direct purchase, immediate |
2039
+ | E-commerce product, add to cart | `BuyAction` | Still a buy intent |
2040
+ | Product not yet released | `PreOrderAction` | Signals future availability |
2041
+ | Physical goods with shipping | `OrderAction` + `deliveryMethod` | Delivery is part of the action |
2042
+ | Vacation rental booking | `RentAction` + `landlord` | Temporal use, not ownership |
2043
+ | Car rental | `RentAction` | Temporal use |
2044
+ | Equipment rental | `RentAction` | Temporal use |
2045
+ | Custom/B2B pricing | `QuoteAction` | Price not fixed |
2046
+ | Consulting services | `QuoteAction` | Scope-dependent pricing |
2047
+ | Marketplace: fixed price | `BuyAction` + `seller` | Direct from seller |
2048
+ | Marketplace: negotiable | `BuyAction` + `QuoteAction` | Both options available |
2049
+ | SaaS free trial | `BuyAction` with `price: 0` | Free is still a transaction |
2050
+ | Donations / support | `TipAction` + `recipient` | Voluntary, no product exchanged |
2051
+ | Subscription | `BuyAction` + `priceSpecification` with `billingPeriod` | Recurring purchase |
2052
+
2053
+ ---
2054
+
2055
+ ## Multi-type entities
2056
+
2057
+ An entity can have multiple `@type` values. This is useful when an entity
2058
+ legitimately belongs to more than one type:
2059
+
2060
+ ```ts
2061
+ buildPiece({
2062
+ '@type': ['Organization', 'Brand'],
2063
+ '@id': ids.organization('acme'),
2064
+ name: 'Acme',
2065
+ url: 'https://acme.com/',
2066
+ logo: {
2067
+ /* ... */
2068
+ },
2069
+ });
2070
+ ```
2071
+
2072
+ This is appropriate for companies that are also consumer-facing brands.
2073
+
2074
+ Common multi-type combinations:
2075
+
2076
+ - `['Organization', 'Brand']` — Company with brand identity
2077
+ - `['LocalBusiness', 'Restaurant']` — Specific local business type
2078
+ - `['Person', 'Patient']` — Context-specific
2079
+ - `['WebPage', 'ItemPage']` — Product detail pages
2080
+ - `['WebPage', 'FAQPage']` — FAQ pages (alternative to separate FAQPage entity)
2081
+
2082
+ **Note:** With `buildPiece`, pass the `@type` array directly:
2083
+
2084
+ ```ts
2085
+ buildPiece({
2086
+ '@type': ['Organization', 'Brand'],
2087
+ '@id': ids.organization('acme'),
2088
+ name: 'Acme',
2089
+ url: 'https://acme.com/',
2090
+ });
2091
+ ```
2092
+
2093
+ ---
2094
+
2095
+ ## Rich Organization patterns
2096
+
2097
+ For established businesses, a richer Organization entity improves knowledge
2098
+ graph representation. Here's the full pattern:
2099
+
2100
+ ```ts
2101
+ import type { Organization } from 'schema-dts';
2102
+
2103
+ buildPiece<Organization>({
2104
+ '@type': 'Organization',
2105
+ '@id': ids.organization('acme'),
2106
+ name: 'Acme Corp',
2107
+ url: 'https://acme.com/',
2108
+ logo: 'https://acme.com/logo.png',
2109
+ description: 'We build developer tools.',
2110
+ sameAs: [
2111
+ 'https://twitter.com/acme',
2112
+ 'https://linkedin.com/company/acme',
2113
+ 'https://github.com/acme',
2114
+ 'https://en.wikipedia.org/wiki/Acme_Corp',
2115
+ ],
2116
+ legalName: 'Acme Corp B.V.',
2117
+ foundingDate: '2015-03-01',
2118
+ founder: {
2119
+ '@type': 'Person',
2120
+ name: 'Jane Doe',
2121
+ sameAs: 'https://en.wikipedia.org/wiki/Jane_Doe',
2122
+ },
2123
+ numberOfEmployees: 45,
2124
+ slogan: 'Tools for the modern web',
2125
+ parentOrganization: {
2126
+ '@type': 'Organization',
2127
+ name: 'Parent Holdings Inc',
2128
+ url: 'https://parent.com/',
2129
+ },
2130
+ memberOf: {
2131
+ '@type': 'Organization',
2132
+ name: 'World Wide Web Consortium (W3C)',
2133
+ url: 'https://w3.org/',
2134
+ },
2135
+ address: {
2136
+ '@type': 'PostalAddress',
2137
+ streetAddress: '123 Tech Lane',
2138
+ addressLocality: 'Amsterdam',
2139
+ addressCountry: 'NL',
2140
+ },
2141
+ });
2142
+ ```
2143
+
2144
+ Include as much as is factually accurate. Don't fabricate data. Properties like
2145
+ `numberOfEmployees`, `foundingDate`, and `founder` are especially valuable for
2146
+ knowledge graph matching.
2147
+
2148
+ ---
2149
+
2150
+ ## Rich Person patterns
2151
+
2152
+ For personal sites, a detailed Person entity establishes identity and
2153
+ credibility. jonoalderson.com uses 80+ entities. Here's the extended pattern:
2154
+
2155
+ ```ts
2156
+ import type { Person } from 'schema-dts';
2157
+
2158
+ buildPiece<Person>({
2159
+ '@type': 'Person',
2160
+ '@id': ids.person,
2161
+ name: 'Jane Doe',
2162
+ familyName: 'Doe',
2163
+ birthDate: '1990-01-15',
2164
+ gender: 'female',
2165
+ nationality: { '@id': ids.country('US') },
2166
+ description: 'Software engineer and technical writer.',
2167
+ jobTitle: 'Lead Engineer',
2168
+ knowsLanguage: ['en', 'es', 'pt'],
2169
+ url: 'https://janedoe.com/about/',
2170
+ image: { '@id': ids.personImage },
2171
+ sameAs: [
2172
+ 'https://twitter.com/janedoe',
2173
+ 'https://github.com/janedoe',
2174
+ 'https://linkedin.com/in/janedoe',
2175
+ 'https://bsky.app/profile/janedoe.com',
2176
+ 'https://mastodon.social/@janedoe',
2177
+ 'https://en.wikipedia.org/wiki/Jane_Doe',
2178
+ ],
2179
+ worksFor: [
2180
+ {
2181
+ '@type': 'EmployeeRole',
2182
+ roleName: 'Lead Engineer',
2183
+ startDate: '2022-01',
2184
+ worksFor: { '@id': ids.organization('acme') },
2185
+ },
2186
+ {
2187
+ '@type': 'EmployeeRole',
2188
+ roleName: 'Advisor',
2189
+ startDate: '2024-06',
2190
+ worksFor: { '@id': ids.organization('startup') },
2191
+ },
2192
+ ],
2193
+ spouse: {
2194
+ '@type': 'Person',
2195
+ '@id': `${siteUrl}/#/schema.org/Person/john`,
2196
+ name: 'John Doe',
2197
+ },
2198
+ knowsAbout: ['TypeScript', 'Schema.org', 'Search Engine Optimization', 'Web Performance'],
2199
+ honorificPrefix: 'Dr.',
2200
+ alumniOf: {
2201
+ '@type': 'EducationalOrganization',
2202
+ name: 'MIT',
2203
+ url: 'https://mit.edu/',
2204
+ },
2205
+ award: ['Best Developer Blog 2025', 'Open Source Contributor of the Year 2024'],
2206
+ });
2207
+ ```
2208
+
2209
+ **Practical advice:**
2210
+
2211
+ - `sameAs` is the most impactful property after name and url. It helps search
2212
+ engines connect your entity to external profiles and knowledge bases.
2213
+ - `worksFor` with `EmployeeRole` is better than plain Organization references
2214
+ because it captures role and tenure.
2215
+ - `knowsAbout` helps topical authority signals.
2216
+ - Include a Wikipedia `sameAs` link if one exists; it strongly anchors the
2217
+ entity in the knowledge graph.
2218
+
2219
+ ---
2220
+
2221
+ ## Reference implementations
2222
+
2223
+ Study these live sites for schema.org graph patterns. View any page's graph
2224
+ by searching the page source for `application/ld+json`.
2225
+
2226
+ ### jonoalderson.com
2227
+
2228
+ An extensively structured personal site by Jono Alderson (former Head of SEO
2229
+ at Yoast, one of the foremost schema.org experts). Uses `BlogPosting` for
2230
+ articles and has one of the richest Person schemas on the web:
2231
+
2232
+ **Notable patterns:**
2233
+
2234
+ - 80+ entities on the homepage, 12+ on article pages
2235
+ - `BlogPosting` instead of `Article` for blog content
2236
+ - Person entity with `birthDate`, `birthPlace`, `nationality`, `award`,
2237
+ `spouse`, `pronouns`, employment history via `EmployeeRole`
2238
+ - Organization entities for every company in work history
2239
+ - Separate `Blog` entity as part of the WebSite
2240
+ - `SearchAction` with `EntryPoint` and `PropertyValueSpecification` on WebSite
2241
+
2242
+ ### meta.com (product pages)
2243
+
2244
+ Meta's product pages (e.g. `/ai-glasses/ray-ban-meta-wayfarer-gen-2/`) are
2245
+ an excellent reference for e-commerce schema:
2246
+
2247
+ **Notable patterns:**
2248
+
2249
+ - `ProductGroup` with `hasVariant` array pointing to individual `Product` entities
2250
+ - 14 product variants, each with their own `sku`, `color`, `size`, and `Offer`
2251
+ - `MerchantReturnPolicy` with return window, method, and shipping cost
2252
+ - `OfferShippingDetails` with delivery cost and shipping destination
2253
+ - `BreadcrumbList` for product category navigation
2254
+ - `ItemPage` (WebPage subtype) for product detail pages
2255
+ - `Organization` with `legalName` ("Meta Platforms, Inc.") and social profiles
2256
+ - Dual brand references (Ray-Ban + Meta)
2257
+
2258
+ **Key takeaway for e-commerce:** Use `ProductGroup` when a product has variants
2259
+ (sizes, colors). Each variant gets its own `Product` entity with a unique `sku`.
2260
+ The `ProductGroup` ties them together.
2261
+
2262
+ ### joost.blog
2263
+
2264
+ This library's own reference consumer. Astro site using `<Seo>` from
2265
+ `@jdevalk/astro-seo-graph`. Source code at
2266
+ [github.com/jdevalk/joost.blog](https://github.com/jdevalk/joost.blog).
2267
+
2268
+ **Notable patterns:**
2269
+
2270
+ - Schema endpoints at `/schema/post.json`, `/schema/video.json`,
2271
+ `/schema/page.json` with full article bodies (markdown-stripped, max 10K chars)
2272
+ - Schema map at `/schemamap.xml` for agent discovery
2273
+ - Person entity with 7 Organization references via `EmployeeRole`
2274
+ - Country entity for nationality
2275
+ - Family members (spouse, children) on homepage/about page
2276
+ - `ProfilePage` for about, `CollectionPage` for listings
2277
+
2278
+ ### Validating against these references
2279
+
2280
+ When building a new site, compare your JSON-LD output against one of these
2281
+ references for the same page type. Use Google's Rich Results Test
2282
+ (https://search.google.com/test/rich-results) and check that:
2283
+
2284
+ 1. Every `@id` reference resolves to an entity in the graph
2285
+ 2. The entity relationship tree is complete (WebSite → WebPage → Article)
2286
+ 3. The publisher chain is correct (Article.publisher and WebSite.publisher match)
2287
+
2288
+ ---
2289
+
2290
+ ## Astro integration guide
2291
+
2292
+ ### The `<Seo>` component
2293
+
2294
+ Single component for all head metadata. Import from the Astro package:
2295
+
2296
+ ```astro
2297
+ ---
2298
+ import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
2299
+ ---
2300
+
2301
+ <html>
2302
+ <head>
2303
+ <Seo
2304
+ title="Page Title | Site Name"
2305
+ description="Page description for search engines."
2306
+ canonical="https://example.com/page/"
2307
+ ogType="article"
2308
+ ogImage="https://example.com/og/page.jpg"
2309
+ ogImageAlt="Description of the image"
2310
+ ogImageWidth={1200}
2311
+ ogImageHeight={675}
2312
+ siteName="Site Name"
2313
+ locale="en_US"
2314
+ twitter={{ card: 'summary_large_image', site: '@handle', creator: '@handle' }}
2315
+ article={{
2316
+ publishedTime: new Date('2026-01-15'),
2317
+ modifiedTime: new Date('2026-03-01'),
2318
+ authors: ['https://example.com/about/'],
2319
+ tags: ['TypeScript', 'SEO'],
2320
+ section: 'Technology',
2321
+ }}
2322
+ graph={graph}
2323
+ noindex={false}
2324
+ extraLinks={[
2325
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
2326
+ { rel: 'sitemap', href: '/sitemap-index.xml' },
2327
+ { rel: 'alternate', type: 'application/rss+xml', href: '/feed.xml', title: 'RSS' },
2328
+ ]}
2329
+ extraMeta={[
2330
+ { name: 'author', content: 'Jane Doe' },
2331
+ ]}
2332
+ />
2333
+ </head>
2334
+ <body>...</body>
2335
+ </html>
2336
+ ```
2337
+
2338
+ ### `<Seo>` props reference
2339
+
2340
+ | Prop | Type | Required | Default | Purpose |
2341
+ | --------------- | ------------------------------------------------------------------------------- | -------- | ---------------- | ------------------------------------ |
2342
+ | `title` | `string` | Yes | — | Full page title |
2343
+ | `titleTemplate` | `string` | No | — | Template with `%s` placeholder |
2344
+ | `description` | `string` | No | — | Meta description |
2345
+ | `canonical` | `string \| URL` | No | Current page URL | Canonical URL |
2346
+ | `ogType` | `'website' \| 'article' \| 'profile' \| 'book'` | No | `'website'` | Open Graph type |
2347
+ | `ogImage` | `string` | No | — | OG image (absolute URL) |
2348
+ | `ogImageAlt` | `string` | No | — | OG image alt text |
2349
+ | `ogImageWidth` | `number` | No | — | OG image width (px) |
2350
+ | `ogImageHeight` | `number` | No | — | OG image height (px) |
2351
+ | `siteName` | `string` | No | — | Site name for OG |
2352
+ | `locale` | `string` | No | `'en_US'` | OG locale |
2353
+ | `twitter` | `{ card?, site?, creator? }` | No | — | Twitter card settings |
2354
+ | `article` | `{ publishedTime?, modifiedTime?, expirationTime?, authors?, tags?, section? }` | No | — | Article OG metadata |
2355
+ | `noindex` | `boolean` | No | `false` | Emit `robots: noindex` |
2356
+ | `graph` | `object \| null` | No | — | JSON-LD graph from `assembleGraph()` |
2357
+ | `alternates` | `{ defaultLocale?, entries[] }` | No | — | hreflang alternate links |
2358
+ | `extraLinks` | `Array<Record<string, string>>` | No | — | Additional `<link>` elements |
2359
+ | `extraMeta` | `Array<Record<string, string>>` | No | — | Additional `<meta>` elements |
2360
+
2361
+ ### hreflang alternates
2362
+
2363
+ For multilingual sites:
2364
+
2365
+ ```astro
2366
+ <Seo
2367
+ title="Hello"
2368
+ alternates={{
2369
+ defaultLocale: 'en',
2370
+ entries: [
2371
+ { hreflang: 'en', href: 'https://example.com/hello/' },
2372
+ { hreflang: 'fr-CA', href: 'https://example.com/fr-ca/bonjour/' },
2373
+ { hreflang: 'nl', href: 'https://example.com/nl/hallo/' },
2374
+ ],
2375
+ }}
2376
+ />
2377
+ ```
2378
+
2379
+ **Rules:**
2380
+
2381
+ - Absolute URLs only. Relative, protocol-relative, and non-http schemes are dropped.
2382
+ - Include the current page (self-referential hreflang is required by Google).
2383
+ - BCP 47 tags are auto-normalized (fr-ca becomes fr-CA).
2384
+ - `x-default` is added automatically, pointing at `defaultLocale`.
2385
+ - If fewer than 2 entries survive validation, nothing is emitted.
2386
+
2387
+ ### Schema endpoints
2388
+
2389
+ Expose a corpus-wide JSON-LD graph as an API endpoint:
2390
+
2391
+ ```ts
2392
+ // src/pages/schema/post.json.ts
2393
+ import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
2394
+ import { getCollection } from 'astro:content';
2395
+ import { makeIds, buildWebPage, buildArticle } from '@jdevalk/seo-graph-core';
2396
+
2397
+ const ids = makeIds({ siteUrl: 'https://example.com' });
2398
+
2399
+ export const GET = createSchemaEndpoint({
2400
+ entries: () => getCollection('blog'),
2401
+ mapper: (post) => {
2402
+ const url = `https://example.com/${post.id}/`;
2403
+ return [
2404
+ buildWebPage(
2405
+ {
2406
+ url,
2407
+ name: post.data.title,
2408
+ isPartOf: { '@id': ids.website },
2409
+ breadcrumb: { '@id': ids.breadcrumb(url) },
2410
+ datePublished: post.data.publishDate,
2411
+ },
2412
+ ids,
2413
+ ),
2414
+ buildArticle(
2415
+ {
2416
+ url,
2417
+ isPartOf: { '@id': ids.webPage(url) },
2418
+ author: { '@id': ids.person },
2419
+ publisher: { '@id': ids.person },
2420
+ headline: post.data.title,
2421
+ description: post.data.excerpt ?? '',
2422
+ datePublished: post.data.publishDate,
2423
+ },
2424
+ ids,
2425
+ ),
2426
+ ];
2427
+ },
2428
+ cacheControl: 'max-age=300', // optional, defaults to 5 minutes
2429
+ indent: 2, // optional, defaults to 2
2430
+ });
2431
+ ```
2432
+
2433
+ **Options:**
2434
+ | Option | Type | Default | Purpose |
2435
+ |---|---|---|---|
2436
+ | `entries` | `() => Promise<Entry[]>` | — | Async content source |
2437
+ | `mapper` | `(entry: Entry) => GraphEntity[]` | — | Convert entry to schema pieces |
2438
+ | `cacheControl` | `string \| null` | `'max-age=300'` | Cache-Control header. `null` to omit. |
2439
+ | `contentType` | `string` | `'application/ld+json'` | Response content type |
2440
+ | `indent` | `number` | `2` | JSON indentation. `0` for compact. |
2441
+
2442
+ ### Schema map discovery
2443
+
2444
+ Provide a sitemap-style XML document listing your schema endpoints:
2445
+
2446
+ ```ts
2447
+ // src/pages/schemamap.xml.ts
2448
+ import { createSchemaMap } from '@jdevalk/astro-seo-graph';
2449
+
2450
+ export const GET = createSchemaMap({
2451
+ siteUrl: 'https://example.com',
2452
+ entries: [
2453
+ { path: '/schema/post.json', lastModified: new Date('2026-04-10') },
2454
+ { path: '/schema/video.json', lastModified: new Date('2026-03-15') },
2455
+ { path: '/schema/page.json', lastModified: new Date('2026-02-01') },
2456
+ ],
2457
+ });
2458
+ ```
2459
+
2460
+ **Schema map entry options:**
2461
+ | Field | Type | Default | Purpose |
2462
+ |---|---|---|---|
2463
+ | `path` | `string` | — | Relative path to schema endpoint |
2464
+ | `lastModified` | `Date` | — | Last modification date |
2465
+ | `changeFreq` | Sitemap frequency string | `'daily'` | Update frequency hint |
2466
+ | `priority` | `number` | `0.8` | Priority hint (0.0-1.0) |
2467
+
2468
+ ### The `aggregate` function
2469
+
2470
+ The engine behind `createSchemaEndpoint`. Use directly when you need custom
2471
+ assembly, caching, or multi-collection merging:
2472
+
2473
+ ```ts
2474
+ import { aggregate } from '@jdevalk/astro-seo-graph';
2475
+
2476
+ const result = aggregate({
2477
+ entries: await getCollection('blog'),
2478
+ mapper: (post) => [...buildPiecesForPost(post)],
2479
+ });
2480
+ // result = { '@context': 'https://schema.org', '@graph': [...] }
2481
+ ```
2482
+
2483
+ ### Zod content helpers
2484
+
2485
+ Use in `src/content.config.ts` to validate SEO fields on content collections:
2486
+
2487
+ ```ts
2488
+ import { defineCollection, z } from 'astro:content';
2489
+ import { seoSchema, imageSchema } from '@jdevalk/astro-seo-graph';
2490
+
2491
+ const blog = defineCollection({
2492
+ schema: ({ image }) =>
2493
+ z.object({
2494
+ title: z.string(),
2495
+ publishDate: z.coerce.date(),
2496
+ excerpt: z.string().optional(),
2497
+ featureImage: imageSchema(image).optional(),
2498
+ seo: seoSchema(image).optional(),
2499
+ }),
2500
+ });
2501
+ ```
2502
+
2503
+ **`seoSchema(image)` shape:**
2504
+
2505
+ ```ts
2506
+ {
2507
+ title: z.string().min(5).max(120).optional(),
2508
+ description: z.string().min(15).max(160).optional(),
2509
+ image: imageSchema(image).optional(),
2510
+ pageType: z.enum(['website', 'article']).default('website'),
2511
+ }
2512
+ ```
2513
+
2514
+ **`imageSchema(image)` shape:**
2515
+
2516
+ ```ts
2517
+ {
2518
+ src: image(),
2519
+ alt: z.string().optional(),
2520
+ }
2521
+ ```
2522
+
2523
+ ### `buildAstroSeoProps`
2524
+
2525
+ Pure-TS function that powers `<Seo>` internally. Use when you want to feed a
2526
+ different head component:
2527
+
2528
+ ```ts
2529
+ import { buildAstroSeoProps } from '@jdevalk/astro-seo-graph';
2530
+
2531
+ const astroSeoProps = buildAstroSeoProps(mySeoProps, Astro.url.href);
2532
+ // Pass to astro-seo's <SEO> or any custom head component
2533
+ ```
2534
+
2535
+ ### `buildAlternateLinks`
2536
+
2537
+ Pure helper for hreflang link generation. No Astro runtime needed — safe
2538
+ to use from non-Astro contexts:
2539
+
2540
+ ```ts
2541
+ import { buildAlternateLinks } from '@jdevalk/astro-seo-graph';
2542
+
2543
+ const links = buildAlternateLinks({
2544
+ defaultLocale: 'en',
2545
+ entries: [
2546
+ { hreflang: 'en', href: 'https://example.com/hello/' },
2547
+ { hreflang: 'fr', href: 'https://example.com/fr/bonjour/' },
2548
+ ],
2549
+ });
2550
+ // → [{ rel: 'alternate', hreflang: 'en', href: '...' }, ..., { hreflang: 'x-default', ... }]
2551
+ ```
2552
+
2553
+ ---
2554
+
2555
+ ## Complete integration example
2556
+
2557
+ Here's how joost.blog wires everything together. Use this as a reference
2558
+ for a full personal blog setup.
2559
+
2560
+ ### 1. Content config (`src/content.config.ts`)
2561
+
2562
+ ```ts
2563
+ import { defineCollection, z } from 'astro:content';
2564
+ import { seoSchema, imageSchema } from '@jdevalk/astro-seo-graph';
2565
+
2566
+ const blog = defineCollection({
2567
+ schema: ({ image }) =>
2568
+ z.object({
2569
+ title: z.string(),
2570
+ excerpt: z.string().optional(),
2571
+ publishDate: z.coerce.date(),
2572
+ updatedDate: z.coerce.date().optional(),
2573
+ featureImage: imageSchema(image).optional(),
2574
+ featureImageAlt: z.string().optional(),
2575
+ categories: z.array(z.string()).optional(),
2576
+ draft: z.boolean().default(false),
2577
+ seo: seoSchema(image).optional(),
2578
+ }),
2579
+ });
2580
+ ```
2581
+
2582
+ ### 2. Schema utility (`src/utils/schema/index.ts`)
2583
+
2584
+ ```ts
2585
+ import {
2586
+ makeIds,
2587
+ assembleGraph,
2588
+ buildWebSite,
2589
+ buildPiece,
2590
+ buildWebPage,
2591
+ buildArticle,
2592
+ buildBreadcrumbList,
2593
+ buildImageObject,
2594
+ buildSiteNavigationElement,
2595
+ } from '@jdevalk/seo-graph-core';
2596
+ import type { Person } from 'schema-dts';
2597
+
2598
+ const SITE_URL = 'https://example.com';
2599
+ export const ids = makeIds({ siteUrl: SITE_URL, personUrl: `${SITE_URL}/about/` });
2600
+
2601
+ // Site-wide entities — included on every page
2602
+ function siteWideEntities() {
2603
+ return [
2604
+ buildWebSite(
2605
+ { url: `${SITE_URL}/`, name: 'My Blog', publisher: { '@id': ids.person } },
2606
+ ids,
2607
+ ),
2608
+ buildPiece<Person>({
2609
+ '@type': 'Person',
2610
+ '@id': ids.person,
2611
+ name: 'Jane Doe',
2612
+ url: `${SITE_URL}/about/`,
2613
+ image: { '@id': ids.personImage },
2614
+ sameAs: ['...'],
2615
+ }),
2616
+ buildImageObject(
2617
+ { id: ids.personImage, url: `${SITE_URL}/jane.jpg`, width: 400, height: 400 },
2618
+ ids,
2619
+ ),
2620
+ buildSiteNavigationElement(
2621
+ {
2622
+ name: 'Main navigation',
2623
+ isPartOf: { '@id': ids.website },
2624
+ items: [
2625
+ { name: 'Home', url: `${SITE_URL}/` },
2626
+ { name: 'Blog', url: `${SITE_URL}/blog/` },
2627
+ { name: 'About', url: `${SITE_URL}/about/` },
2628
+ ],
2629
+ },
2630
+ ids,
2631
+ ),
2632
+ ];
2633
+ }
2634
+
2635
+ // Page-specific graph builder
2636
+ export function buildSchemaGraph(opts: {
2637
+ pageType: string;
2638
+ url: string;
2639
+ title: string;
2640
+ description: string;
2641
+ publishDate?: Date;
2642
+ updatedDate?: Date;
2643
+ featureImageUrl?: string;
2644
+ category?: string;
2645
+ }) {
2646
+ const pieces = [...siteWideEntities()];
2647
+ const { url, title, description, publishDate, updatedDate, featureImageUrl, category } = opts;
2648
+
2649
+ switch (opts.pageType) {
2650
+ case 'blogPost':
2651
+ pieces.push(
2652
+ buildWebPage(
2653
+ {
2654
+ url,
2655
+ name: title,
2656
+ isPartOf: { '@id': ids.website },
2657
+ breadcrumb: { '@id': ids.breadcrumb(url) },
2658
+ datePublished: publishDate,
2659
+ dateModified: updatedDate,
2660
+ primaryImage: featureImageUrl
2661
+ ? { '@id': ids.primaryImage(url) }
2662
+ : undefined,
2663
+ },
2664
+ ids,
2665
+ ),
2666
+ buildArticle(
2667
+ {
2668
+ url,
2669
+ isPartOf: { '@id': ids.webPage(url) },
2670
+ author: { '@id': ids.person },
2671
+ publisher: { '@id': ids.person },
2672
+ headline: title,
2673
+ description,
2674
+ datePublished: publishDate!,
2675
+ dateModified: updatedDate,
2676
+ image: featureImageUrl ? { '@id': ids.primaryImage(url) } : undefined,
2677
+ articleSection: category,
2678
+ },
2679
+ ids,
2680
+ ),
2681
+ buildBreadcrumbList(
2682
+ {
2683
+ url,
2684
+ items: [
2685
+ { name: 'Home', url: `${SITE_URL}/` },
2686
+ { name: 'Blog', url: `${SITE_URL}/blog/` },
2687
+ { name: title, url },
2688
+ ],
2689
+ },
2690
+ ids,
2691
+ ),
2692
+ );
2693
+ if (featureImageUrl) {
2694
+ pieces.push(
2695
+ buildImageObject(
2696
+ { pageUrl: url, url: featureImageUrl, width: 1200, height: 630 },
2697
+ ids,
2698
+ ),
2699
+ );
2700
+ }
2701
+ break;
2702
+ case 'blogListing':
2703
+ pieces.push(
2704
+ buildWebPage(
2705
+ { url, name: title, isPartOf: { '@id': ids.website } },
2706
+ ids,
2707
+ 'CollectionPage',
2708
+ ),
2709
+ );
2710
+ break;
2711
+ case 'about':
2712
+ pieces.push(
2713
+ buildWebPage(
2714
+ {
2715
+ url,
2716
+ name: title,
2717
+ isPartOf: { '@id': ids.website },
2718
+ about: { '@id': ids.person },
2719
+ },
2720
+ ids,
2721
+ 'ProfilePage',
2722
+ ),
2723
+ );
2724
+ break;
2725
+ default:
2726
+ pieces.push(buildWebPage({ url, name: title, isPartOf: { '@id': ids.website } }, ids));
2727
+ }
2728
+
2729
+ return assembleGraph(pieces);
2730
+ }
2731
+ ```
2732
+
2733
+ ### 3. Head component (`src/components/BaseHead.astro`)
2734
+
2735
+ ```astro
2736
+ ---
2737
+ import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
2738
+ import { buildSchemaGraph } from '../utils/schema';
2739
+
2740
+ const { title, description, pageType = 'page', publishDate, categories } = Astro.props;
2741
+
2742
+ const graph = buildSchemaGraph({
2743
+ pageType,
2744
+ url: Astro.url.href,
2745
+ title,
2746
+ description,
2747
+ publishDate,
2748
+ category: categories?.[0],
2749
+ });
2750
+
2751
+ const ogImage = new URL(`/og/${Astro.url.pathname.replace(/\//g, '') || 'index'}.jpg`, Astro.site).toString();
2752
+ ---
2753
+
2754
+ <Seo
2755
+ title={`${title} | My Blog`}
2756
+ description={description}
2757
+ canonical={Astro.url.href}
2758
+ ogType={pageType === 'blogPost' ? 'article' : 'website'}
2759
+ ogImage={ogImage}
2760
+ ogImageAlt={title}
2761
+ ogImageWidth={1200}
2762
+ ogImageHeight={675}
2763
+ siteName="My Blog"
2764
+ twitter={{ card: 'summary_large_image', site: '@handle' }}
2765
+ article={pageType === 'blogPost' && publishDate ? { publishedTime: publishDate, tags: categories } : undefined}
2766
+ graph={graph}
2767
+ extraLinks={[
2768
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
2769
+ { rel: 'sitemap', href: '/sitemap-index.xml' },
2770
+ ]}
2771
+ />
2772
+ ```
2773
+
2774
+ ### 4. Schema endpoint (`src/pages/schema/post.json.ts`)
2775
+
2776
+ ```ts
2777
+ import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
2778
+ import { getCollection } from 'astro:content';
2779
+ import { ids } from '../../utils/schema';
2780
+ import { buildWebPage, buildArticle, buildBreadcrumbList } from '@jdevalk/seo-graph-core';
2781
+
2782
+ const SITE_URL = 'https://example.com';
2783
+
2784
+ export const GET = createSchemaEndpoint({
2785
+ entries: async () => {
2786
+ const posts = await getCollection('blog');
2787
+ return posts.filter((p) => !p.data.draft);
2788
+ },
2789
+ mapper: (post) => {
2790
+ const url = `${SITE_URL}/${post.id}/`;
2791
+ return [
2792
+ buildWebPage(
2793
+ {
2794
+ url,
2795
+ name: post.data.title,
2796
+ isPartOf: { '@id': ids.website },
2797
+ breadcrumb: { '@id': ids.breadcrumb(url) },
2798
+ datePublished: post.data.publishDate,
2799
+ },
2800
+ ids,
2801
+ ),
2802
+ buildArticle(
2803
+ {
2804
+ url,
2805
+ isPartOf: { '@id': ids.webPage(url) },
2806
+ author: { '@id': ids.person },
2807
+ publisher: { '@id': ids.person },
2808
+ headline: post.data.title,
2809
+ description: post.data.excerpt ?? '',
2810
+ datePublished: post.data.publishDate,
2811
+ dateModified: post.data.updatedDate,
2812
+ },
2813
+ ids,
2814
+ ),
2815
+ buildBreadcrumbList(
2816
+ {
2817
+ url,
2818
+ items: [
2819
+ { name: 'Home', url: `${SITE_URL}/` },
2820
+ { name: 'Blog', url: `${SITE_URL}/blog/` },
2821
+ { name: post.data.title, url },
2822
+ ],
2823
+ },
2824
+ ids,
2825
+ ),
2826
+ ];
2827
+ },
2828
+ });
2829
+ ```
2830
+
2831
+ ### 5. Schema map (`src/pages/schemamap.xml.ts`)
2832
+
2833
+ ```ts
2834
+ import { createSchemaMap } from '@jdevalk/astro-seo-graph';
2835
+
2836
+ export const GET = createSchemaMap({
2837
+ siteUrl: 'https://example.com',
2838
+ entries: [{ path: '/schema/post.json', lastModified: new Date() }],
2839
+ });
2840
+ ```
2841
+
2842
+ ---
2843
+
2844
+ ## Advanced patterns
2845
+
2846
+ ### Multiple organizations
2847
+
2848
+ When a person works for several companies, create an organization for each:
2849
+
2850
+ ```ts
2851
+ import type { Organization, Person } from 'schema-dts';
2852
+
2853
+ const orgs = [
2854
+ { slug: 'acme', name: 'Acme Corp', url: 'https://acme.com/' },
2855
+ { slug: 'side-project', name: 'Side Project Inc', url: 'https://sideproject.com/' },
2856
+ ];
2857
+
2858
+ const orgPieces = orgs.map((org) =>
2859
+ buildPiece<Organization>({
2860
+ '@type': 'Organization',
2861
+ '@id': ids.organization(org.slug),
2862
+ name: org.name,
2863
+ url: org.url,
2864
+ }),
2865
+ );
2866
+
2867
+ const personPiece = buildPiece<Person>({
2868
+ '@type': 'Person',
2869
+ '@id': ids.person,
2870
+ name: 'Jane Doe',
2871
+ worksFor: [
2872
+ {
2873
+ '@type': 'EmployeeRole',
2874
+ roleName: 'CEO',
2875
+ startDate: '2020',
2876
+ worksFor: { '@id': ids.organization('acme') },
2877
+ },
2878
+ {
2879
+ '@type': 'EmployeeRole',
2880
+ roleName: 'Advisor',
2881
+ startDate: '2023',
2882
+ worksFor: { '@id': ids.organization('side-project') },
2883
+ },
2884
+ ],
2885
+ });
2886
+ ```
2887
+
2888
+ ### Organization subtypes
2889
+
2890
+ Use the schema.org subtype directly as the `buildPiece` generic for full type safety:
2891
+
2892
+ ```ts
2893
+ import type { Dentist, Hotel } from 'schema-dts';
2894
+
2895
+ buildPiece<Dentist>({
2896
+ '@type': 'Dentist',
2897
+ '@id': ids.organization('clinic'),
2898
+ name: 'Smile Dental',
2899
+ medicalSpecialty: 'Dentistry',
2900
+ });
2901
+ buildPiece<Hotel>({
2902
+ '@type': 'Hotel',
2903
+ '@id': ids.organization('hotel'),
2904
+ name: 'Grand Hotel',
2905
+ starRating: { '@type': 'Rating', ratingValue: 4 },
2906
+ checkinTime: '15:00',
2907
+ checkoutTime: '11:00',
2908
+ });
2909
+ ```
2910
+
2911
+ ### Multi-author blogs
2912
+
2913
+ When different posts have different authors, use `buildPiece<Person>` with a
2914
+ custom `@id` for each author (reserving `ids.person` for the site-wide person):
2915
+
2916
+ ```ts
2917
+ import type { Person } from 'schema-dts';
2918
+
2919
+ const authorId = `${siteUrl}/authors/${authorSlug}/#person`;
2920
+ const authorPiece = buildPiece<Person>({
2921
+ '@type': 'Person',
2922
+ '@id': authorId,
2923
+ name: authorName,
2924
+ url: `${siteUrl}/authors/${authorSlug}/`,
2925
+ image: authorAvatarUrl,
2926
+ });
2927
+ const articlePiece = buildArticle(
2928
+ {
2929
+ url,
2930
+ isPartOf: { '@id': ids.webPage(url) },
2931
+ author: { '@id': authorId },
2932
+ publisher: { '@id': ids.organization('company') },
2933
+ headline: title,
2934
+ description,
2935
+ datePublished,
2936
+ },
2937
+ ids,
2938
+ );
2939
+ ```
2940
+
2941
+ ### Non-Astro usage (Next.js, SvelteKit, etc.)
2942
+
2943
+ Use `@jdevalk/seo-graph-core` directly. Build your graph, then inject it as a
2944
+ `<script type="application/ld+json">` tag:
2945
+
2946
+ ```tsx
2947
+ // Next.js example
2948
+ import {
2949
+ makeIds,
2950
+ assembleGraph,
2951
+ buildWebSite,
2952
+ buildWebPage,
2953
+ buildArticle,
2954
+ } from '@jdevalk/seo-graph-core';
2955
+
2956
+ const ids = makeIds({ siteUrl: 'https://example.com' });
2957
+
2958
+ export default function BlogPost({ post }) {
2959
+ const url = `https://example.com/blog/${post.slug}`;
2960
+ const graph = assembleGraph([
2961
+ buildWebSite(
2962
+ { url: 'https://example.com/', name: 'My Site', publisher: { '@id': ids.person } },
2963
+ ids,
2964
+ ),
2965
+ buildWebPage(
2966
+ {
2967
+ url,
2968
+ name: post.title,
2969
+ isPartOf: { '@id': ids.website },
2970
+ datePublished: new Date(post.date),
2971
+ },
2972
+ ids,
2973
+ ),
2974
+ buildArticle(
2975
+ {
2976
+ url,
2977
+ isPartOf: { '@id': ids.webPage(url) },
2978
+ author: { '@id': ids.person },
2979
+ publisher: { '@id': ids.person },
2980
+ headline: post.title,
2981
+ description: post.excerpt,
2982
+ datePublished: new Date(post.date),
2983
+ },
2984
+ ids,
2985
+ ),
2986
+ ]);
2987
+
2988
+ return (
2989
+ <>
2990
+ <Head>
2991
+ <title>{post.title} | My Site</title>
2992
+ <script
2993
+ type="application/ld+json"
2994
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }}
2995
+ />
2996
+ </Head>
2997
+ <article>{post.content}</article>
2998
+ </>
2999
+ );
3000
+ }
3001
+ ```
3002
+
3003
+ ---
3004
+
3005
+ ## Common mistakes
3006
+
3007
+ 1. **Forgetting to link entities.** Every `Article` needs `isPartOf` pointing to
3008
+ its `WebPage`. Every `WebPage` needs `isPartOf` pointing to the `WebSite`.
3009
+ Missing links produce valid JSON-LD but an unconnected graph that search
3010
+ engines can't walk.
3011
+
3012
+ 2. **Duplicating site-wide entities.** `WebSite` and `Person` should appear once
3013
+ in the graph. `assembleGraph` deduplicates by `@id` (first wins), so it's
3014
+ safe to include them in every page's piece array.
3015
+
3016
+ 3. **Using wrong WebPage subtype.** Archive/listing pages should be
3017
+ `CollectionPage`, not `WebPage`. About pages should be `ProfilePage`.
3018
+
3019
+ 4. **Relative URLs.** All URLs in the graph must be absolute
3020
+ (`https://example.com/page/`, not `/page/`).
3021
+
3022
+ 5. **Missing trailing slashes.** Be consistent. If your site uses trailing
3023
+ slashes, use them everywhere in the graph. Mismatched URLs create
3024
+ duplicate entities.
3025
+
3026
+ 6. **Inlining entities instead of referencing.** Don't put a full Person object
3027
+ inside an Article's `author` field. Use `{ '@id': ids.person }` and let the
3028
+ graph resolver connect them.
3029
+
3030
+ 7. **Not including the graph in the page head.** Building the graph is step one.
3031
+ You still need to render it as `<script type="application/ld+json">` in
3032
+ your page. The `<Seo>` component handles this via the `graph` prop. In
3033
+ non-Astro setups, inject it manually.
3034
+
3035
+ 8. **Omitting `@context`.** Always use `assembleGraph()` to wrap your pieces.
3036
+ It adds `"@context": "https://schema.org"` automatically. Don't build the
3037
+ envelope by hand.
3038
+
3039
+ ---
3040
+
3041
+ ## Validating your output
3042
+
3043
+ After building a graph, validate it:
3044
+
3045
+ 1. **Google Rich Results Test:** https://search.google.com/test/rich-results
3046
+ 2. **Schema.org Validator:** https://validator.schema.org/
3047
+ 3. **Check `@id` resolution:** Every `{ "@id": "..." }` reference in the graph
3048
+ should have a matching entity with that `@id`. If not, the reference is
3049
+ broken.
3050
+
3051
+ ---
3052
+
3053
+ ## Repository structure
3054
+
3055
+ ```
3056
+ seo-graph/
3057
+ ├── packages/
3058
+ │ ├── seo-graph-core/ # @jdevalk/seo-graph-core
3059
+ │ │ └── src/
3060
+ │ │ ├── index.ts # All exports
3061
+ │ │ ├── ids.ts # makeIds, IdFactory
3062
+ │ │ ├── assemble.ts # assembleGraph, deduplicateByGraphId
3063
+ │ │ ├── builders/ # One file per piece builder
3064
+ │ │ └── types.ts # GraphEntity, Reference, SchemaGraph
3065
+ │ └── astro-seo-graph/ # @jdevalk/astro-seo-graph
3066
+ │ └── src/
3067
+ │ ├── index.ts # All exports
3068
+ │ ├── Seo.astro # <Seo> component
3069
+ │ ├── routes.ts # createSchemaEndpoint, createSchemaMap
3070
+ │ ├── aggregator.ts # aggregate
3071
+ │ ├── alternates.ts # buildAlternateLinks
3072
+ │ ├── content.ts # seoSchema, imageSchema
3073
+ │ └── components/
3074
+ │ └── seo-props.ts # buildAstroSeoProps
3075
+ ├── AGENTS.md # This file
3076
+ ├── README.md # Project overview
3077
+ └── pnpm-workspace.yaml
3078
+ ```