@jdevalk/astro-seo-graph 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/AGENTS.md +501 -442
  2. package/package.json +2 -2
package/AGENTS.md CHANGED
@@ -31,13 +31,11 @@ Two packages:
31
31
  │ @jdevalk/seo-graph-core │
32
32
  │ │
33
33
  │ makeIds() IdFactory │
34
- │ buildArticle() buildPerson() │
35
34
  │ buildWebSite() buildWebPage() │
36
- buildOrganization()
35
+ buildArticle() buildPiece()
37
36
  │ buildBreadcrumbList() │
38
37
  │ buildImageObject() buildVideoObject() │
39
38
  │ buildSiteNavigationElement() │
40
- │ buildCustomPiece() │
41
39
  │ assembleGraph() │
42
40
  │ deduplicateByGraphId() │
43
41
  └──────────────┬────────────────────────────────────┘
@@ -153,8 +151,11 @@ resolution.
153
151
 
154
152
  ## Piece builders reference
155
153
 
156
- Every builder takes an input object and the `IdFactory`, and returns a
157
- `GraphEntity` (a plain object with `@type` and usually `@id`).
154
+ Every builder takes an input object and returns a `GraphEntity` (a plain object
155
+ with `@type` and usually `@id`). The specialized builders (`buildWebSite`,
156
+ `buildWebPage`, `buildArticle`, etc.) also take the `IdFactory` as a second
157
+ parameter. The generic `buildPiece` builder takes only the input object — you
158
+ set the `@id` directly in the input.
158
159
 
159
160
  ### buildWebSite
160
161
 
@@ -170,7 +171,7 @@ buildWebSite(
170
171
  about: { '@id': ids.person }, // optional — what this site is about
171
172
  inLanguage: 'en-US', // optional — default content language
172
173
  hasPart: { '@id': ids.navigation }, // optional — navigation ref
173
- extra: {}, // optional escape hatch for any schema.org property
174
+ // ...additional schema-dts properties accepted at top level
174
175
  },
175
176
  ids,
176
177
  );
@@ -178,7 +179,7 @@ buildWebSite(
178
179
 
179
180
  **Adding a SearchAction** (recommended for sites with search):
180
181
 
181
- Use the `extra` field to add a `potentialAction` with a `SearchAction`. This
182
+ Add a `potentialAction` with a `SearchAction` directly at the top level. This
182
183
  tells search engines and agents how to search your site:
183
184
 
184
185
  ```ts
@@ -187,122 +188,24 @@ buildWebSite(
187
188
  url: 'https://example.com/',
188
189
  name: 'My Site',
189
190
  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') },
191
+ potentialAction: {
192
+ '@type': 'SearchAction',
193
+ target: {
194
+ '@type': 'EntryPoint',
195
+ urlTemplate: 'https://example.com/?s={search_term_string}',
241
196
  },
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',
197
+ 'query-input': {
198
+ '@type': 'PropertyValueSpecification',
199
+ valueRequired: true,
200
+ valueName: 'search_term_string',
284
201
  },
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
202
  },
297
203
  },
298
204
  ids,
299
- 'Restaurant',
300
205
  );
301
206
  ```
302
207
 
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`.
208
+ This is the pattern used by most WordPress sites and many other CMSes.
306
209
 
307
210
  ### buildWebPage
308
211
 
@@ -326,7 +229,7 @@ buildWebPage(
326
229
  license: 'https://creativecommons.org/licenses/by/4.0/', // optional — license URL
327
230
  isAccessibleForFree: true, // optional
328
231
  potentialAction: [], // optional — defaults to ReadAction
329
- extra: {}, // optional escape hatch
232
+ // ...additional schema-dts properties accepted at top level
330
233
  },
331
234
  ids,
332
235
  'WebPage',
@@ -361,7 +264,7 @@ buildArticle(
361
264
  articleSection: 'Technology', // optional — top-level category
362
265
  wordCount: 1500, // optional
363
266
  articleBody: 'The full text...', // optional — plain text, max ~10K chars
364
- extra: {}, // optional escape hatch
267
+ // ...additional schema-dts properties accepted at top level
365
268
  },
366
269
  ids,
367
270
  'Article',
@@ -387,7 +290,7 @@ buildBreadcrumbList({
387
290
  { name: 'Blog', url: 'https://example.com/blog/' },
388
291
  { name: 'My Post', url: 'https://example.com/blog/my-post/' },
389
292
  ],
390
- extra: {}, // optional
293
+ // ...additional schema-dts properties accepted at top level
391
294
  }, ids);
392
295
  ````
393
296
 
@@ -411,7 +314,7 @@ buildImageObject(
411
314
  height: 630, // required
412
315
  inLanguage: 'en-US', // optional
413
316
  caption: 'A photo of...', // optional
414
- extra: {}, // optional
317
+ // ...additional schema-dts properties accepted at top level
415
318
  },
416
319
  ids,
417
320
  );
@@ -445,7 +348,7 @@ buildVideoObject(
445
348
  uploadDate: new Date('2026-01-15'), // optional
446
349
  duration: 'PT30M', // optional — ISO 8601
447
350
  transcript: 'Full transcript text...', // optional
448
- extra: {}, // optional
351
+ // ...additional schema-dts properties accepted at top level
449
352
  },
450
353
  ids,
451
354
  );
@@ -471,37 +374,143 @@ buildSiteNavigationElement(
471
374
  { name: 'Blog', url: 'https://example.com/blog/' },
472
375
  { name: 'About', url: 'https://example.com/about/' },
473
376
  ],
474
- extra: {}, // optional
377
+ // ...additional schema-dts properties accepted at top level
475
378
  },
476
379
  ids,
477
380
  );
478
381
  ```
479
382
 
480
- ### buildCustomPiece
383
+ ### buildPiece
384
+
385
+ The generic typed builder for any schema.org type. This is the go-to builder
386
+ for `Person`, `Organization`, `Blog`, `Product`, `Recipe`, `Event`, `Course`,
387
+ `SoftwareApplication`, `VacationRental`, `FAQPage`, `PodcastSeries`,
388
+ `PodcastEpisode`, and any other schema.org type not covered by the specialized
389
+ builders (`buildWebSite`, `buildWebPage`, `buildArticle`, etc.).
481
390
 
482
- Escape hatch for any schema.org type not covered by the built-in builders.
391
+ Pass a `schema-dts` type as the generic parameter for full autocomplete.
392
+ The `@type` value in the input narrows union types to the matching leaf — so
393
+ `buildPiece<Product>` with `'@type': 'Product'` gives `ProductLeaf` autocomplete.
394
+ No need to import Leaf types separately.
395
+
396
+ Callers are responsible for setting `@id` using the `IdFactory` (e.g.
397
+ `ids.person`, `ids.organization('slug')`) or a custom ID string.
483
398
 
484
399
  ```ts
485
- buildCustomPiece({
400
+ import type { Person, Organization, Restaurant, Blog, Product, Recipe, Event } from 'schema-dts';
401
+
402
+ // Person (site-wide)
403
+ buildPiece<Person>({
404
+ '@type': 'Person',
405
+ '@id': ids.person,
406
+ name: 'Jane Doe',
407
+ url: 'https://example.com/about/',
408
+ image: { '@id': ids.personImage },
409
+ sameAs: ['https://twitter.com/janedoe', 'https://github.com/janedoe'],
410
+ jobTitle: 'Lead Engineer',
411
+ worksFor: [
412
+ {
413
+ '@type': 'EmployeeRole',
414
+ roleName: 'Lead Engineer',
415
+ startDate: '2022-01-01',
416
+ worksFor: { '@id': ids.organization('acme') },
417
+ },
418
+ ],
419
+ });
420
+
421
+ // Organization
422
+ buildPiece<Organization>({
423
+ '@type': 'Organization',
424
+ '@id': ids.organization('acme'),
425
+ name: 'Acme Corp',
426
+ url: 'https://acme.com/',
427
+ logo: 'https://acme.com/logo.png',
428
+ sameAs: ['https://twitter.com/acme'],
429
+ });
430
+
431
+ // Organization subtype (e.g. Restaurant) — use the subtype directly as the generic
432
+ buildPiece<Restaurant>({
433
+ '@type': 'Restaurant',
434
+ '@id': ids.organization('chez-example'),
435
+ name: 'Chez Example',
436
+ url: 'https://chezexample.com/',
437
+ servesCuisine: 'French',
438
+ priceRange: '$$$',
439
+ address: {
440
+ '@type': 'PostalAddress',
441
+ streetAddress: '123 Rue de la Paix',
442
+ addressLocality: 'Paris',
443
+ addressCountry: 'FR',
444
+ },
445
+ });
446
+
447
+ // Product
448
+ buildPiece<Product>({
449
+ '@type': 'Product',
450
+ '@id': `${url}#product`,
451
+ name: 'Running Shoe',
452
+ brand: 'Nike',
453
+ sku: 'ABC123',
454
+ offers: { '@type': 'Offer', price: 99.99, priceCurrency: 'USD' },
455
+ });
456
+
457
+ // Blog
458
+ buildPiece<Blog>({
459
+ '@type': 'Blog',
460
+ '@id': `${siteUrl}/blog/#blog`,
461
+ name: 'My Blog',
462
+ url: `${siteUrl}/blog/`,
463
+ publisher: { '@id': ids.person },
464
+ inLanguage: 'en-US',
465
+ });
466
+
467
+ // Recipe
468
+ buildPiece<Recipe>({
486
469
  '@type': 'Recipe',
487
- '@id': 'https://example.com/recipes/pasta/#recipe',
470
+ '@id': `${url}#recipe`,
488
471
  name: 'Simple Pasta',
489
472
  author: { '@id': ids.person },
490
- prepTime: 'PT15M',
473
+ prepTime: 'PT10M',
491
474
  cookTime: 'PT20M',
492
- recipeIngredient: ['200g pasta', '2 cloves garlic', '...'],
475
+ totalTime: 'PT30M',
476
+ recipeYield: '4 servings',
477
+ recipeCategory: 'Main course',
478
+ recipeCuisine: 'Italian',
479
+ recipeIngredient: ['400g spaghetti', '200g guanciale', '4 egg yolks'],
493
480
  recipeInstructions: [
494
- {
495
- '@type': 'HowToStep',
496
- text: 'Boil the pasta.',
497
- },
481
+ { '@type': 'HowToStep', text: 'Boil the spaghetti.' },
482
+ { '@type': 'HowToStep', text: 'Fry the guanciale.' },
498
483
  ],
499
484
  });
485
+
486
+ // Event
487
+ buildPiece<Event>({
488
+ '@type': 'Event',
489
+ '@id': 'https://example.com/events/conf/#event',
490
+ name: 'JavaScript Conference 2026',
491
+ startDate: '2026-09-15T09:00:00+02:00',
492
+ endDate: '2026-09-17T18:00:00+02:00',
493
+ location: {
494
+ '@type': 'Place',
495
+ name: 'Congress Center',
496
+ },
497
+ });
498
+ ```
499
+
500
+ Without a generic, the input is untyped — any properties are accepted:
501
+
502
+ ```ts
503
+ buildPiece({
504
+ '@type': 'Event',
505
+ '@id': 'https://example.com/events/conf/#event',
506
+ name: 'JavaScript Conference 2026',
507
+ });
500
508
  ```
501
509
 
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.
510
+ **Always prefer the typed generic** (`buildPiece<Event>`) over the
511
+ untyped form. The generic gives you autocomplete for every property on the
512
+ chosen type, making it much harder to miss recommended fields like
513
+ `potentialAction`, `geo`, or `offers`.
505
514
 
506
515
  ### assembleGraph
507
516
 
@@ -523,6 +532,16 @@ const graph = assembleGraph([
523
532
  **Always call this last.** It handles deduplication: if multiple pages produce
524
533
  the same `WebSite` or `Person` entity (same `@id`), the first occurrence wins.
525
534
 
535
+ **Dangling reference validation:** Pass `warnOnDanglingReferences: true` to
536
+ validate that every `{ '@id': '...' }` reference in the graph resolves to an
537
+ actual entity. This helps catch broken links — for example, a `WebSite`
538
+ referencing a `Person` that was never included in the pieces array.
539
+
540
+ ```ts
541
+ const graph = assembleGraph(pieces, { warnOnDanglingReferences: true });
542
+ // Warns: [seo-graph] Dangling reference in WebSite: { "@id": "..." } does not match any entity in the graph.
543
+ ```
544
+
526
545
  ### deduplicateByGraphId
527
546
 
528
547
  The dedup engine on its own, for custom assembly workflows.
@@ -549,19 +568,21 @@ about page.
549
568
  **For every page** (site-wide entities):
550
569
 
551
570
  - `buildWebSite` — publisher points to Person
552
- - `buildPerson` — the blog author
571
+ - `buildPiece<Person>` — the blog author
553
572
  - `buildImageObject` — person's profile photo (use `id: ids.personImage`)
554
- - `buildCustomPiece` — a `Blog` entity representing the blog as a publication
573
+ - `buildPiece<Blog>` — a `Blog` entity representing the blog as a publication
555
574
 
556
575
  The `Blog` entity is a `CreativeWork` that represents the blog as a whole,
557
576
  separate from the `WebSite`. Individual `BlogPosting` entries reference the
558
577
  Blog via `isPartOf`. This is the pattern used by jonoalderson.com.
559
578
 
560
579
  ```ts
580
+ import type { Blog } from 'schema-dts';
581
+
561
582
  const blogId = `${siteUrl}/blog/#blog`;
562
583
 
563
584
  // Include on every page as a site-wide entity
564
- buildCustomPiece({
585
+ buildPiece<Blog>({
565
586
  '@type': 'Blog',
566
587
  '@id': blogId,
567
588
  name: 'My Blog',
@@ -577,13 +598,15 @@ buildCustomPiece({
577
598
  Use `BlogPosting` instead of `Article` and link it to the Blog:
578
599
 
579
600
  ```ts
601
+ import type { Person, Blog } from 'schema-dts';
602
+
580
603
  const blogId = `${siteUrl}/blog/#blog`;
581
604
 
582
605
  const pieces = [
583
606
  buildWebSite({ url: siteUrl, name: 'My Blog', publisher: { '@id': ids.person } }, ids),
584
- buildPerson({ name: 'Jane Doe', url: aboutUrl, image: { '@id': ids.personImage }, sameAs: [...] }, ids),
607
+ buildPiece<Person>({ '@type': 'Person', '@id': ids.person, name: 'Jane Doe', url: aboutUrl, image: { '@id': ids.personImage }, sameAs: [...] }),
585
608
  buildImageObject({ id: ids.personImage, url: profilePhotoUrl, width: 400, height: 400 }, ids),
586
- buildCustomPiece({
609
+ buildPiece<Blog>({
587
610
  '@type': 'Blog',
588
611
  '@id': blogId,
589
612
  name: 'My Blog',
@@ -599,11 +622,10 @@ const pieces = [
599
622
  dateModified,
600
623
  author: { '@id': ids.person },
601
624
  publisher: { '@id': ids.person },
602
- isPartOf: { '@id': ids.webPage(url) },
625
+ isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }],
603
626
  image: { '@id': ids.primaryImage(url) },
604
627
  articleSection: category,
605
628
  wordCount,
606
- extra: { isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }] },
607
629
  }, ids, 'BlogPosting'),
608
630
  buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
609
631
  buildImageObject({ pageUrl: url, url: featureImageUrl, width: 1200, height: 630 }, ids),
@@ -611,9 +633,9 @@ const pieces = [
611
633
  const graph = assembleGraph(pieces);
612
634
  ```
613
635
 
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.
636
+ **Note:** The `isPartOf` array links the posting to both the `WebPage` and the
637
+ `Blog`. If you don't need the `Blog` link, just use
638
+ `isPartOf: { '@id': ids.webPage(url) }` directly.
617
639
 
618
640
  **Blog listing** (`/blog/`):
619
641
 
@@ -715,14 +737,16 @@ A multi-author blog owned by a company.
715
737
  entities.
716
738
 
717
739
  ```ts
740
+ import type { Organization, Blog, Person } from 'schema-dts';
741
+
718
742
  const ids = makeIds({ siteUrl: 'https://acme.com' });
719
743
 
720
744
  // Site-wide
721
745
  const blogId = 'https://acme.com/blog/#blog';
722
746
  const siteEntities = [
723
- buildOrganization({ slug: 'acme', name: 'Acme Corp', url: 'https://acme.com/', logo: logoUrl, sameAs: [...] }, ids),
747
+ buildPiece<Organization>({ '@type': 'Organization', '@id': ids.organization('acme'), name: 'Acme Corp', url: 'https://acme.com/', logo: logoUrl, sameAs: [...] }),
724
748
  buildWebSite({ url: 'https://acme.com/', name: 'Acme Blog', publisher: { '@id': ids.organization('acme') } }, ids),
725
- buildCustomPiece({
749
+ buildPiece<Blog>({
726
750
  '@type': 'Blog',
727
751
  '@id': blogId,
728
752
  name: 'The Acme Blog',
@@ -735,7 +759,7 @@ const siteEntities = [
735
759
  const authorId = 'https://acme.com/team/jane/#person';
736
760
  const postPieces = [
737
761
  ...siteEntities,
738
- buildCustomPiece({ '@type': 'Person', '@id': authorId, name: 'Jane Doe', url: 'https://acme.com/team/jane/' }),
762
+ buildPiece<Person>({ '@type': 'Person', '@id': authorId, name: 'Jane Doe', url: 'https://acme.com/team/jane/' }),
739
763
  buildWebPage({ url, name: title, isPartOf: { '@id': ids.website }, datePublished }, ids),
740
764
  buildArticle({
741
765
  url,
@@ -744,8 +768,7 @@ const postPieces = [
744
768
  datePublished,
745
769
  author: { '@id': authorId },
746
770
  publisher: { '@id': ids.organization('acme') },
747
- isPartOf: { '@id': ids.webPage(url) },
748
- extra: { isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }] },
771
+ isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }],
749
772
  }, ids, 'BlogPosting'),
750
773
  buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
751
774
  ];
@@ -755,16 +778,23 @@ const postPieces = [
755
778
 
756
779
  ### E-commerce / product page
757
780
 
758
- Use `buildCustomPiece` for `Product`. The core doesn't have a built-in product
759
- builder because product schemas vary wildly.
781
+ Use `buildPiece<Product>` for `Product` and `buildPiece<ProductGroup>` for `ProductGroup` entities.
760
782
 
761
783
  **Simple product (single variant):**
762
784
 
763
785
  ```ts
786
+ import type { Organization, Product } from 'schema-dts';
787
+
764
788
  const ids = makeIds({ siteUrl: 'https://shop.example.com' });
765
789
 
766
790
  const pieces = [
767
- buildOrganization({ slug: 'shop', name: 'Example Shop', url: siteUrl, logo: logoUrl }, ids),
791
+ buildPiece<Organization>({
792
+ '@type': 'Organization',
793
+ '@id': ids.organization('shop'),
794
+ name: 'Example Shop',
795
+ url: siteUrl,
796
+ logo: logoUrl,
797
+ }),
768
798
  buildWebSite(
769
799
  { url: siteUrl, name: 'Example Shop', publisher: { '@id': ids.organization('shop') } },
770
800
  ids,
@@ -789,13 +819,12 @@ const pieces = [
789
819
  },
790
820
  ids,
791
821
  ),
792
- buildCustomPiece({
822
+ buildPiece<Product>({
793
823
  '@type': 'Product',
794
824
  '@id': `${url}#product`,
795
825
  name: productName,
796
826
  description: productDescription,
797
- image: productImageUrl,
798
- brand: { '@type': 'Brand', name: 'Nike' },
827
+ brand: 'Nike',
799
828
  sku: 'ABC123',
800
829
  offers: {
801
830
  '@type': 'Offer',
@@ -818,6 +847,7 @@ const pieces = [
818
847
  },
819
848
  seller: { '@id': ids.organization('shop') },
820
849
  },
850
+ image: productImageUrl,
821
851
  }),
822
852
  ];
823
853
  ```
@@ -828,6 +858,8 @@ When a product has multiple variants (e.g. sizes, colors), use `ProductGroup`
828
858
  as the parent and individual `Product` entities for each variant:
829
859
 
830
860
  ```ts
861
+ import type { Product, ProductGroup } from 'schema-dts';
862
+
831
863
  const variants = [
832
864
  {
833
865
  sku: 'SHOE-BLK-10',
@@ -857,25 +889,23 @@ const variants = [
857
889
 
858
890
  const pieces = [
859
891
  // ...site-wide + WebPage + BreadcrumbList...
860
- buildCustomPiece({
892
+ buildPiece<ProductGroup>({
861
893
  '@type': 'ProductGroup',
862
- '@id': `${url}#product-group`,
894
+ '@id': `${url}#product`,
863
895
  name: 'Running Shoe',
864
896
  description: productDescription,
865
- brand: { '@type': 'Brand', name: 'Nike' },
897
+ brand: 'Nike',
898
+ url,
866
899
  productGroupID: 'running-shoe',
867
900
  variesBy: ['https://schema.org/color', 'https://schema.org/size'],
868
- hasVariant: variants.map((v) => ({ '@id': `${url}#variant-${v.sku}` })),
901
+ hasVariant: variants.map((v) => ({ '@id': `${url}#product-${v.sku}` })),
869
902
  }),
870
903
  ...variants.map((v) =>
871
- buildCustomPiece({
904
+ buildPiece<Product>({
872
905
  '@type': 'Product',
873
- '@id': `${url}#variant-${v.sku}`,
906
+ '@id': `${url}#product-${v.sku}`,
874
907
  name: v.name,
875
908
  sku: v.sku,
876
- color: v.color,
877
- size: v.size,
878
- image: [productImageUrl],
879
909
  offers: {
880
910
  '@type': 'Offer',
881
911
  price: v.price,
@@ -896,6 +926,9 @@ const pieces = [
896
926
  shippingDestination: { '@type': 'DefinedRegion', addressCountry: 'US' },
897
927
  },
898
928
  },
929
+ color: v.color,
930
+ size: v.size,
931
+ image: [productImageUrl],
899
932
  }),
900
933
  ),
901
934
  ];
@@ -913,48 +946,43 @@ import type { Restaurant } from 'schema-dts';
913
946
  const ids = makeIds({ siteUrl: 'https://chezexample.com' });
914
947
 
915
948
  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
- },
949
+ buildPiece<Restaurant>({
950
+ '@type': 'Restaurant',
951
+ '@id': ids.organization('chez-example'),
952
+ name: 'Chez Example',
953
+ url: 'https://chezexample.com/',
954
+ logo: logoUrl,
955
+ sameAs: ['https://instagram.com/chezexample'],
956
+ address: {
957
+ '@type': 'PostalAddress',
958
+ streetAddress: '123 Rue de la Paix',
959
+ addressLocality: 'Paris',
960
+ postalCode: '75002',
961
+ addressCountry: 'FR',
954
962
  },
955
- ids,
956
- 'Restaurant',
957
- ),
963
+ telephone: '+33-1-23-45-67-89',
964
+ priceRange: '$$$',
965
+ servesCuisine: 'French',
966
+ geo: {
967
+ '@type': 'GeoCoordinates',
968
+ latitude: 48.8698,
969
+ longitude: 2.3311,
970
+ },
971
+ openingHoursSpecification: [
972
+ {
973
+ '@type': 'OpeningHoursSpecification',
974
+ dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
975
+ opens: '12:00',
976
+ closes: '14:30',
977
+ },
978
+ {
979
+ '@type': 'OpeningHoursSpecification',
980
+ dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
981
+ opens: '19:00',
982
+ closes: '22:30',
983
+ },
984
+ ],
985
+ }),
958
986
  buildWebSite(
959
987
  {
960
988
  url: siteUrl,
@@ -981,20 +1009,21 @@ const pieces = [
981
1009
  A freelancer or agency showcasing work.
982
1010
 
983
1011
  ```ts
1012
+ import type { Person } from 'schema-dts';
1013
+
984
1014
  const ids = makeIds({ siteUrl: 'https://janedoe.design' });
985
1015
 
986
1016
  // Homepage — CollectionPage showcasing work
987
1017
  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
- ),
1018
+ buildPiece<Person>({
1019
+ '@type': 'Person',
1020
+ '@id': ids.person,
1021
+ name: 'Jane Doe',
1022
+ jobTitle: 'Product Designer',
1023
+ url: siteUrl,
1024
+ image: { '@id': ids.personImage },
1025
+ sameAs: [dribbble, linkedin],
1026
+ }),
998
1027
  buildImageObject({ id: ids.personImage, url: headshot, width: 400, height: 400 }, ids),
999
1028
  buildWebSite({ url: siteUrl, name: 'Jane Doe Design', publisher: { '@id': ids.person } }, ids),
1000
1029
  buildWebPage(
@@ -1055,13 +1084,18 @@ const projectPieces = [
1055
1084
  A docs site for a software project or API.
1056
1085
 
1057
1086
  ```ts
1087
+ import type { Organization } from 'schema-dts';
1088
+
1058
1089
  const ids = makeIds({ siteUrl: 'https://docs.example.com' });
1059
1090
 
1060
1091
  const pieces = [
1061
- buildOrganization(
1062
- { slug: 'example', name: 'Example Inc', url: 'https://example.com/', logo: logoUrl },
1063
- ids,
1064
- ),
1092
+ buildPiece<Organization>({
1093
+ '@type': 'Organization',
1094
+ '@id': ids.organization('example'),
1095
+ name: 'Example Inc',
1096
+ url: 'https://example.com/',
1097
+ logo: logoUrl,
1098
+ }),
1065
1099
  buildWebSite(
1066
1100
  {
1067
1101
  url: siteUrl,
@@ -1109,15 +1143,23 @@ container for `PodcastEpisode`. Include the series as a site-wide entity.
1109
1143
  **Video podcast (YouTube-based):**
1110
1144
 
1111
1145
  ```ts
1146
+ import type { Person, PodcastSeries } from 'schema-dts';
1147
+
1112
1148
  const ids = makeIds({ siteUrl: 'https://podcast.example.com' });
1113
1149
  const seriesId = `${siteUrl}#podcast-series`;
1114
1150
 
1115
1151
  // Episode page
1116
1152
  const pieces = [
1117
- buildPerson({ name: 'Host Name', url: aboutUrl, image: { '@id': ids.personImage } }, ids),
1153
+ buildPiece<Person>({
1154
+ '@type': 'Person',
1155
+ '@id': ids.person,
1156
+ name: 'Host Name',
1157
+ url: aboutUrl,
1158
+ image: { '@id': ids.personImage },
1159
+ }),
1118
1160
  buildImageObject({ id: ids.personImage, url: hostPhotoUrl, width: 400, height: 400 }, ids),
1119
1161
  buildWebSite({ url: siteUrl, name: 'My Podcast', publisher: { '@id': ids.person } }, ids),
1120
- buildCustomPiece({
1162
+ buildPiece<PodcastSeries>({
1121
1163
  '@type': 'PodcastSeries',
1122
1164
  '@id': seriesId,
1123
1165
  name: 'My Podcast',
@@ -1170,12 +1212,14 @@ const pieces = [
1170
1212
  Use `PodcastEpisode` linked to the `PodcastSeries`:
1171
1213
 
1172
1214
  ```ts
1215
+ import type { PodcastEpisode } from 'schema-dts';
1216
+
1173
1217
  const seriesId = `${siteUrl}#podcast-series`;
1174
1218
 
1175
1219
  const pieces = [
1176
1220
  // ...site-wide entities including PodcastSeries...
1177
1221
  buildWebPage({ url, name: episodeTitle, isPartOf: { '@id': ids.website }, datePublished }, ids),
1178
- buildCustomPiece({
1222
+ buildPiece<PodcastEpisode>({
1179
1223
  '@type': 'PodcastEpisode',
1180
1224
  '@id': `${url}#episode`,
1181
1225
  name: episodeTitle,
@@ -1225,10 +1269,12 @@ const pieces = [
1225
1269
  ### Vacation rental / accommodation
1226
1270
 
1227
1271
  ```ts
1272
+ import type { Person, VacationRental } from 'schema-dts';
1273
+
1228
1274
  const ids = makeIds({ siteUrl: 'https://myhouse.example.com' });
1229
1275
 
1230
1276
  const pieces = [
1231
- buildPerson({ name: 'Owner Name', url: siteUrl }, ids),
1277
+ buildPiece<Person>({ '@type': 'Person', '@id': ids.person, name: 'Owner Name', url: siteUrl }),
1232
1278
  buildWebSite({ url: siteUrl, name: 'Villa Example', publisher: { '@id': ids.person } }, ids),
1233
1279
  buildWebPage(
1234
1280
  {
@@ -1238,7 +1284,7 @@ const pieces = [
1238
1284
  },
1239
1285
  ids,
1240
1286
  ),
1241
- buildCustomPiece({
1287
+ buildPiece<VacationRental>({
1242
1288
  '@type': 'VacationRental',
1243
1289
  '@id': `${siteUrl}#rental`,
1244
1290
  name: 'Villa Example',
@@ -1289,6 +1335,8 @@ const pieces = [
1289
1335
  ### Recipe site
1290
1336
 
1291
1337
  ```ts
1338
+ import type { Recipe } from 'schema-dts';
1339
+
1292
1340
  const ids = makeIds({ siteUrl: 'https://recipes.example.com' });
1293
1341
 
1294
1342
  const pieces = [
@@ -1314,14 +1362,11 @@ const pieces = [
1314
1362
  },
1315
1363
  ids,
1316
1364
  ),
1317
- buildCustomPiece({
1365
+ buildPiece<Recipe>({
1318
1366
  '@type': 'Recipe',
1319
1367
  '@id': `${url}#recipe`,
1320
1368
  name: recipeName,
1321
- description: recipeDescription,
1322
- image: recipeImageUrl,
1323
1369
  author: { '@id': ids.person },
1324
- datePublished: publishDate.toISOString(),
1325
1370
  prepTime: 'PT15M',
1326
1371
  cookTime: 'PT45M',
1327
1372
  totalTime: 'PT1H',
@@ -1344,6 +1389,9 @@ const pieces = [
1344
1389
  { '@type': 'HowToStep', text: 'Mix egg yolks with pecorino.' },
1345
1390
  { '@type': 'HowToStep', text: 'Combine and serve immediately.' },
1346
1391
  ],
1392
+ description: recipeDescription,
1393
+ image: recipeImageUrl,
1394
+ datePublished: publishDate.toISOString(),
1347
1395
  }),
1348
1396
  ];
1349
1397
  ```
@@ -1353,7 +1401,9 @@ const pieces = [
1353
1401
  ### Event page
1354
1402
 
1355
1403
  ```ts
1356
- buildCustomPiece({
1404
+ import type { Event } from 'schema-dts';
1405
+
1406
+ buildPiece<Event>({
1357
1407
  '@type': 'Event',
1358
1408
  '@id': `${url}#event`,
1359
1409
  name: 'JavaScript Conference 2026',
@@ -1389,8 +1439,16 @@ buildCustomPiece({
1389
1439
  ### SaaS / software product landing page
1390
1440
 
1391
1441
  ```ts
1442
+ import type { Organization, SoftwareApplication } from 'schema-dts';
1443
+
1392
1444
  const pieces = [
1393
- buildOrganization({ slug: 'myapp', name: 'MyApp Inc', url: siteUrl, logo: logoUrl }, ids),
1445
+ buildPiece<Organization>({
1446
+ '@type': 'Organization',
1447
+ '@id': ids.organization('myapp'),
1448
+ name: 'MyApp Inc',
1449
+ url: siteUrl,
1450
+ logo: logoUrl,
1451
+ }),
1394
1452
  buildWebSite(
1395
1453
  { url: siteUrl, name: 'MyApp', publisher: { '@id': ids.organization('myapp') } },
1396
1454
  ids,
@@ -1403,7 +1461,7 @@ const pieces = [
1403
1461
  },
1404
1462
  ids,
1405
1463
  ),
1406
- buildCustomPiece({
1464
+ buildPiece<SoftwareApplication>({
1407
1465
  '@type': 'SoftwareApplication',
1408
1466
  '@id': `${siteUrl}#app`,
1409
1467
  name: 'MyApp',
@@ -1443,13 +1501,15 @@ const pieces = [
1443
1501
  Combine `WebPage` with a `FAQPage` custom piece:
1444
1502
 
1445
1503
  ```ts
1504
+ import type { FAQPage } from 'schema-dts';
1505
+
1446
1506
  const pieces = [
1447
1507
  // ...site-wide entities...
1448
1508
  buildWebPage(
1449
1509
  { url, name: 'Frequently Asked Questions', isPartOf: { '@id': ids.website } },
1450
1510
  ids,
1451
1511
  ),
1452
- buildCustomPiece({
1512
+ buildPiece<FAQPage>({
1453
1513
  '@type': 'FAQPage',
1454
1514
  '@id': `${url}#faq`,
1455
1515
  mainEntity: [
@@ -1479,7 +1539,9 @@ const pieces = [
1479
1539
  ### Course / educational content
1480
1540
 
1481
1541
  ```ts
1482
- buildCustomPiece({
1542
+ import type { Course } from 'schema-dts';
1543
+
1544
+ buildPiece<Course>({
1483
1545
  '@type': 'Course',
1484
1546
  '@id': `${url}#course`,
1485
1547
  name: 'Introduction to TypeScript',
@@ -1534,17 +1596,19 @@ policies. It can be applied to `Organization`, `Person`, or `CreativeWork`
1534
1596
  search engines and AI agents about your content's credibility.
1535
1597
 
1536
1598
  ```ts
1599
+ import type { Person, Blog, Organization } from 'schema-dts';
1600
+
1537
1601
  // On a Person entity (personal blog)
1538
- buildPerson({
1602
+ buildPiece<Person>({
1603
+ '@type': 'Person',
1604
+ '@id': ids.person,
1539
1605
  name: 'Jane Doe',
1540
1606
  url: aboutUrl,
1541
- extra: {
1542
- publishingPrinciples: `${siteUrl}/editorial-policy/`,
1543
- },
1544
- }, ids),
1607
+ publishingPrinciples: `${siteUrl}/editorial-policy/`,
1608
+ }),
1545
1609
 
1546
1610
  // On a Blog entity
1547
- buildCustomPiece({
1611
+ buildPiece<Blog>({
1548
1612
  '@type': 'Blog',
1549
1613
  '@id': blogId,
1550
1614
  name: 'My Blog',
@@ -1554,13 +1618,12 @@ buildCustomPiece({
1554
1618
  }),
1555
1619
 
1556
1620
  // On an Organization (news site, company blog)
1557
- buildOrganization({
1558
- slug: 'newsroom',
1621
+ buildPiece<Organization>({
1622
+ '@type': 'Organization',
1623
+ '@id': ids.organization('newsroom'),
1559
1624
  name: 'The Daily Example',
1560
- extra: {
1561
- publishingPrinciples: `${siteUrl}/ethics/`,
1562
- },
1563
- }, ids),
1625
+ publishingPrinciples: `${siteUrl}/ethics/`,
1626
+ }),
1564
1627
  ```
1565
1628
 
1566
1629
  ### Specialized policy sub-properties
@@ -1569,21 +1632,22 @@ For news and media organizations, schema.org has more specific sub-properties
1569
1632
  of `publishingPrinciples`:
1570
1633
 
1571
1634
  ```ts
1572
- buildOrganization({
1573
- slug: 'newsroom',
1635
+ import type { Organization } from 'schema-dts';
1636
+
1637
+ buildPiece<Organization>({
1638
+ '@type': 'Organization',
1639
+ '@id': ids.organization('newsroom'),
1574
1640
  name: 'The Daily Example',
1575
1641
  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),
1642
+ publishingPrinciples: `${siteUrl}/editorial-policy/`,
1643
+ correctionsPolicy: `${siteUrl}/corrections/`,
1644
+ verificationFactCheckingPolicy: `${siteUrl}/fact-checking/`,
1645
+ actionableFeedbackPolicy: `${siteUrl}/feedback/`,
1646
+ unnamedSourcesPolicy: `${siteUrl}/sources-policy/`,
1647
+ ownershipFundingInfo: `${siteUrl}/about/ownership/`,
1648
+ diversityStaffingReport: `${siteUrl}/diversity-report/`,
1649
+ masthead: `${siteUrl}/team/`,
1650
+ }),
1587
1651
  ```
1588
1652
 
1589
1653
  ### When to use which
@@ -1621,9 +1685,7 @@ buildWebPage({
1621
1685
  copyrightNotice: '© 2026 Jane Doe. All rights reserved.',
1622
1686
  license: 'https://creativecommons.org/licenses/by/4.0/',
1623
1687
  isAccessibleForFree: true,
1624
- extra: {
1625
- creditText: 'Jane Doe / janedoe.com',
1626
- },
1688
+ creditText: 'Jane Doe / janedoe.com',
1627
1689
  }, ids),
1628
1690
  ```
1629
1691
 
@@ -1634,11 +1696,9 @@ buildArticle({
1634
1696
  url,
1635
1697
  headline: title,
1636
1698
  // ...other article properties...
1637
- extra: {
1638
- copyrightHolder: { '@id': ids.person },
1639
- copyrightYear: 2026,
1640
- license: 'https://creativecommons.org/licenses/by-sa/4.0/',
1641
- },
1699
+ copyrightHolder: { '@id': ids.person },
1700
+ copyrightYear: 2026,
1701
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/',
1642
1702
  }, ids, 'BlogPosting'),
1643
1703
  ```
1644
1704
 
@@ -1771,7 +1831,9 @@ potentialAction: {
1771
1831
  Add to the `Product` or `ProductGroup` entity:
1772
1832
 
1773
1833
  ```ts
1774
- buildCustomPiece({
1834
+ import type { Product } from 'schema-dts';
1835
+
1836
+ buildPiece<Product>({
1775
1837
  '@type': 'Product',
1776
1838
  '@id': `${url}#product`,
1777
1839
  name: productName,
@@ -1824,7 +1886,9 @@ potentialAction: {
1824
1886
  Add to the `VacationRental`, `Product`, or `Car` entity:
1825
1887
 
1826
1888
  ```ts
1827
- buildCustomPiece({
1889
+ import type { VacationRental } from 'schema-dts';
1890
+
1891
+ buildPiece<VacationRental>({
1828
1892
  '@type': 'VacationRental',
1829
1893
  '@id': `${siteUrl}#rental`,
1830
1894
  name: 'Villa Example',
@@ -1933,29 +1997,29 @@ Many entities benefit from multiple actions. A WebSite typically has a
1933
1997
  `SearchAction`; the entities within it have trade actions:
1934
1998
 
1935
1999
  ```ts
2000
+ import type { Product } from 'schema-dts';
2001
+
1936
2002
  // WebSite: how to search
1937
2003
  buildWebSite({
1938
2004
  url: siteUrl,
1939
2005
  name: 'My Shop',
1940
2006
  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
- },
2007
+ potentialAction: {
2008
+ '@type': 'SearchAction',
2009
+ target: {
2010
+ '@type': 'EntryPoint',
2011
+ urlTemplate: `${siteUrl}search?q={search_term_string}`,
2012
+ },
2013
+ 'query-input': {
2014
+ '@type': 'PropertyValueSpecification',
2015
+ valueRequired: true,
2016
+ valueName: 'search_term_string',
1953
2017
  },
1954
2018
  },
1955
2019
  }, ids),
1956
2020
 
1957
2021
  // Product: how to buy
1958
- buildCustomPiece({
2022
+ buildPiece<Product>({
1959
2023
  '@type': 'Product',
1960
2024
  '@id': `${url}#product`,
1961
2025
  name: productName,
@@ -1994,7 +2058,7 @@ An entity can have multiple `@type` values. This is useful when an entity
1994
2058
  legitimately belongs to more than one type:
1995
2059
 
1996
2060
  ```ts
1997
- buildCustomPiece({
2061
+ buildPiece({
1998
2062
  '@type': ['Organization', 'Brand'],
1999
2063
  '@id': ids.organization('acme'),
2000
2064
  name: 'Acme',
@@ -2015,18 +2079,15 @@ Common multi-type combinations:
2015
2079
  - `['WebPage', 'ItemPage']` — Product detail pages
2016
2080
  - `['WebPage', 'FAQPage']` — FAQ pages (alternative to separate FAQPage entity)
2017
2081
 
2018
- **Note:** When using multi-type with `buildOrganization`, pass the primary
2019
- subtype as the third argument and add additional types via `extra`:
2082
+ **Note:** With `buildPiece`, pass the `@type` array directly:
2020
2083
 
2021
2084
  ```ts
2022
- buildOrganization(
2023
- {
2024
- slug: 'acme',
2025
- name: 'Acme',
2026
- extra: { '@type': ['Organization', 'Brand'] },
2027
- },
2028
- ids,
2029
- );
2085
+ buildPiece({
2086
+ '@type': ['Organization', 'Brand'],
2087
+ '@id': ids.organization('acme'),
2088
+ name: 'Acme',
2089
+ url: 'https://acme.com/',
2090
+ });
2030
2091
  ```
2031
2092
 
2032
2093
  ---
@@ -2037,49 +2098,47 @@ For established businesses, a richer Organization entity improves knowledge
2037
2098
  graph representation. Here's the full pattern:
2038
2099
 
2039
2100
  ```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
- },
2101
+ import type { Organization } from 'schema-dts';
2102
+
2103
+ buildPiece<Organization>({
2104
+ '@type': 'Organization',
2105
+ '@id': ids.organization('acme'),
2106
+ name: 'Acme Corp',
2107
+ url: 'https://acme.com/',
2108
+ logo: 'https://acme.com/logo.png',
2109
+ description: 'We build developer tools.',
2110
+ sameAs: [
2111
+ 'https://twitter.com/acme',
2112
+ 'https://linkedin.com/company/acme',
2113
+ 'https://github.com/acme',
2114
+ 'https://en.wikipedia.org/wiki/Acme_Corp',
2115
+ ],
2116
+ legalName: 'Acme Corp B.V.',
2117
+ foundingDate: '2015-03-01',
2118
+ founder: {
2119
+ '@type': 'Person',
2120
+ name: 'Jane Doe',
2121
+ sameAs: 'https://en.wikipedia.org/wiki/Jane_Doe',
2080
2122
  },
2081
- ids,
2082
- );
2123
+ numberOfEmployees: 45,
2124
+ slogan: 'Tools for the modern web',
2125
+ parentOrganization: {
2126
+ '@type': 'Organization',
2127
+ name: 'Parent Holdings Inc',
2128
+ url: 'https://parent.com/',
2129
+ },
2130
+ memberOf: {
2131
+ '@type': 'Organization',
2132
+ name: 'World Wide Web Consortium (W3C)',
2133
+ url: 'https://w3.org/',
2134
+ },
2135
+ address: {
2136
+ '@type': 'PostalAddress',
2137
+ streetAddress: '123 Tech Lane',
2138
+ addressLocality: 'Amsterdam',
2139
+ addressCountry: 'NL',
2140
+ },
2141
+ });
2083
2142
  ```
2084
2143
 
2085
2144
  Include as much as is factually accurate. Don't fabricate data. Properties like
@@ -2094,63 +2153,57 @@ For personal sites, a detailed Person entity establishes identity and
2094
2153
  credibility. jonoalderson.com uses 80+ entities. Here's the extended pattern:
2095
2154
 
2096
2155
  ```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',
2156
+ import type { Person } from 'schema-dts';
2157
+
2158
+ buildPiece<Person>({
2159
+ '@type': 'Person',
2160
+ '@id': ids.person,
2161
+ name: 'Jane Doe',
2162
+ familyName: 'Doe',
2163
+ birthDate: '1990-01-15',
2164
+ gender: 'female',
2165
+ nationality: { '@id': ids.country('US') },
2166
+ description: 'Software engineer and technical writer.',
2167
+ jobTitle: 'Lead Engineer',
2168
+ knowsLanguage: ['en', 'es', 'pt'],
2169
+ url: 'https://janedoe.com/about/',
2170
+ image: { '@id': ids.personImage },
2171
+ sameAs: [
2172
+ 'https://twitter.com/janedoe',
2173
+ 'https://github.com/janedoe',
2174
+ 'https://linkedin.com/in/janedoe',
2175
+ 'https://bsky.app/profile/janedoe.com',
2176
+ 'https://mastodon.social/@janedoe',
2177
+ 'https://en.wikipedia.org/wiki/Jane_Doe',
2178
+ ],
2179
+ worksFor: [
2180
+ {
2181
+ '@type': 'EmployeeRole',
2182
+ roleName: 'Lead Engineer',
2183
+ startDate: '2022-01',
2184
+ worksFor: { '@id': ids.organization('acme') },
2135
2185
  },
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'],
2186
+ {
2187
+ '@type': 'EmployeeRole',
2188
+ roleName: 'Advisor',
2189
+ startDate: '2024-06',
2190
+ worksFor: { '@id': ids.organization('startup') },
2150
2191
  },
2192
+ ],
2193
+ spouse: {
2194
+ '@type': 'Person',
2195
+ '@id': `${siteUrl}/#/schema.org/Person/john`,
2196
+ name: 'John Doe',
2151
2197
  },
2152
- ids,
2153
- );
2198
+ knowsAbout: ['TypeScript', 'Schema.org', 'Search Engine Optimization', 'Web Performance'],
2199
+ honorificPrefix: 'Dr.',
2200
+ alumniOf: {
2201
+ '@type': 'EducationalOrganization',
2202
+ name: 'MIT',
2203
+ url: 'https://mit.edu/',
2204
+ },
2205
+ award: ['Best Developer Blog 2025', 'Open Source Contributor of the Year 2024'],
2206
+ });
2154
2207
  ```
2155
2208
 
2156
2209
  **Practical advice:**
@@ -2533,13 +2586,14 @@ import {
2533
2586
  makeIds,
2534
2587
  assembleGraph,
2535
2588
  buildWebSite,
2536
- buildPerson,
2589
+ buildPiece,
2537
2590
  buildWebPage,
2538
2591
  buildArticle,
2539
2592
  buildBreadcrumbList,
2540
2593
  buildImageObject,
2541
2594
  buildSiteNavigationElement,
2542
2595
  } from '@jdevalk/seo-graph-core';
2596
+ import type { Person } from 'schema-dts';
2543
2597
 
2544
2598
  const SITE_URL = 'https://example.com';
2545
2599
  export const ids = makeIds({ siteUrl: SITE_URL, personUrl: `${SITE_URL}/about/` });
@@ -2551,15 +2605,14 @@ function siteWideEntities() {
2551
2605
  { url: `${SITE_URL}/`, name: 'My Blog', publisher: { '@id': ids.person } },
2552
2606
  ids,
2553
2607
  ),
2554
- buildPerson(
2555
- {
2556
- name: 'Jane Doe',
2557
- url: `${SITE_URL}/about/`,
2558
- image: { '@id': ids.personImage },
2559
- sameAs: ['...'],
2560
- },
2561
- ids,
2562
- ),
2608
+ buildPiece<Person>({
2609
+ '@type': 'Person',
2610
+ '@id': ids.person,
2611
+ name: 'Jane Doe',
2612
+ url: `${SITE_URL}/about/`,
2613
+ image: { '@id': ids.personImage },
2614
+ sameAs: ['...'],
2615
+ }),
2563
2616
  buildImageObject(
2564
2617
  { id: ids.personImage, url: `${SITE_URL}/jane.jpg`, width: 400, height: 400 },
2565
2618
  ids,
@@ -2795,70 +2848,76 @@ export const GET = createSchemaMap({
2795
2848
  When a person works for several companies, create an organization for each:
2796
2849
 
2797
2850
  ```ts
2851
+ import type { Organization, Person } from 'schema-dts';
2852
+
2798
2853
  const orgs = [
2799
2854
  { slug: 'acme', name: 'Acme Corp', url: 'https://acme.com/' },
2800
2855
  { slug: 'side-project', name: 'Side Project Inc', url: 'https://sideproject.com/' },
2801
2856
  ];
2802
2857
 
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,
2858
+ const orgPieces = orgs.map((org) =>
2859
+ buildPiece<Organization>({
2860
+ '@type': 'Organization',
2861
+ '@id': ids.organization(org.slug),
2862
+ name: org.name,
2863
+ url: org.url,
2864
+ }),
2824
2865
  );
2866
+
2867
+ const personPiece = buildPiece<Person>({
2868
+ '@type': 'Person',
2869
+ '@id': ids.person,
2870
+ name: 'Jane Doe',
2871
+ worksFor: [
2872
+ {
2873
+ '@type': 'EmployeeRole',
2874
+ roleName: 'CEO',
2875
+ startDate: '2020',
2876
+ worksFor: { '@id': ids.organization('acme') },
2877
+ },
2878
+ {
2879
+ '@type': 'EmployeeRole',
2880
+ roleName: 'Advisor',
2881
+ startDate: '2023',
2882
+ worksFor: { '@id': ids.organization('side-project') },
2883
+ },
2884
+ ],
2885
+ });
2825
2886
  ```
2826
2887
 
2827
2888
  ### Organization subtypes
2828
2889
 
2829
- Use `schema-dts` generics for full type safety on subtypes:
2890
+ Use the schema.org subtype directly as the `buildPiece` generic for full type safety:
2830
2891
 
2831
2892
  ```ts
2832
- import type { Dentist, Hotel, EducationalOrganization } from 'schema-dts';
2893
+ import type { Dentist, Hotel } from 'schema-dts';
2833
2894
 
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
- );
2895
+ buildPiece<Dentist>({
2896
+ '@type': 'Dentist',
2897
+ '@id': ids.organization('clinic'),
2898
+ name: 'Smile Dental',
2899
+ medicalSpecialty: 'Dentistry',
2900
+ });
2901
+ buildPiece<Hotel>({
2902
+ '@type': 'Hotel',
2903
+ '@id': ids.organization('hotel'),
2904
+ name: 'Grand Hotel',
2905
+ starRating: { '@type': 'Rating', ratingValue: 4 },
2906
+ checkinTime: '15:00',
2907
+ checkoutTime: '11:00',
2908
+ });
2852
2909
  ```
2853
2910
 
2854
2911
  ### Multi-author blogs
2855
2912
 
2856
- When different posts have different authors, use `buildCustomPiece` for author
2857
- Person entities (since `buildPerson` always uses `ids.person` as the `@id`):
2913
+ When different posts have different authors, use `buildPiece<Person>` with a
2914
+ custom `@id` for each author (reserving `ids.person` for the site-wide person):
2858
2915
 
2859
2916
  ```ts
2917
+ import type { Person } from 'schema-dts';
2918
+
2860
2919
  const authorId = `${siteUrl}/authors/${authorSlug}/#person`;
2861
- const authorPiece = buildCustomPiece({
2920
+ const authorPiece = buildPiece<Person>({
2862
2921
  '@type': 'Person',
2863
2922
  '@id': authorId,
2864
2923
  name: authorName,