@jdevalk/astro-seo-graph 0.2.4 → 0.3.1

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