@jdevalk/astro-seo-graph 0.3.1 → 0.4.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.
Files changed (2) hide show
  1. package/AGENTS.md +525 -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,167 @@ 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`.
514
+
515
+ ### Overriding `@id`
516
+
517
+ Every dedicated builder computes an `@id` from the `IdFactory` (e.g.
518
+ `ids.website`, `ids.article(url)`). You can override it by passing `'@id'`
519
+ directly — the explicit value wins:
520
+
521
+ ```ts
522
+ buildBreadcrumbList(
523
+ {
524
+ url,
525
+ items: [
526
+ { name: 'Home', url: siteUrl },
527
+ { name: 'Blog', url: blogUrl },
528
+ ],
529
+ '@id': `${blogUrl}#breadcrumb`, // overrides ids.breadcrumb(url)
530
+ },
531
+ ids,
532
+ );
533
+ ```
534
+
535
+ This works on all builders: `buildWebSite`, `buildWebPage`, `buildArticle`,
536
+ `buildBreadcrumbList`, `buildImageObject`, `buildVideoObject`, and
537
+ `buildSiteNavigationElement`.
505
538
 
506
539
  ### assembleGraph
507
540
 
@@ -523,6 +556,16 @@ const graph = assembleGraph([
523
556
  **Always call this last.** It handles deduplication: if multiple pages produce
524
557
  the same `WebSite` or `Person` entity (same `@id`), the first occurrence wins.
525
558
 
559
+ **Dangling reference validation:** Pass `warnOnDanglingReferences: true` to
560
+ validate that every `{ '@id': '...' }` reference in the graph resolves to an
561
+ actual entity. This helps catch broken links — for example, a `WebSite`
562
+ referencing a `Person` that was never included in the pieces array.
563
+
564
+ ```ts
565
+ const graph = assembleGraph(pieces, { warnOnDanglingReferences: true });
566
+ // Warns: [seo-graph] Dangling reference in WebSite: { "@id": "..." } does not match any entity in the graph.
567
+ ```
568
+
526
569
  ### deduplicateByGraphId
527
570
 
528
571
  The dedup engine on its own, for custom assembly workflows.
@@ -549,19 +592,21 @@ about page.
549
592
  **For every page** (site-wide entities):
550
593
 
551
594
  - `buildWebSite` — publisher points to Person
552
- - `buildPerson` — the blog author
595
+ - `buildPiece<Person>` — the blog author
553
596
  - `buildImageObject` — person's profile photo (use `id: ids.personImage`)
554
- - `buildCustomPiece` — a `Blog` entity representing the blog as a publication
597
+ - `buildPiece<Blog>` — a `Blog` entity representing the blog as a publication
555
598
 
556
599
  The `Blog` entity is a `CreativeWork` that represents the blog as a whole,
557
600
  separate from the `WebSite`. Individual `BlogPosting` entries reference the
558
601
  Blog via `isPartOf`. This is the pattern used by jonoalderson.com.
559
602
 
560
603
  ```ts
604
+ import type { Blog } from 'schema-dts';
605
+
561
606
  const blogId = `${siteUrl}/blog/#blog`;
562
607
 
563
608
  // Include on every page as a site-wide entity
564
- buildCustomPiece({
609
+ buildPiece<Blog>({
565
610
  '@type': 'Blog',
566
611
  '@id': blogId,
567
612
  name: 'My Blog',
@@ -577,13 +622,15 @@ buildCustomPiece({
577
622
  Use `BlogPosting` instead of `Article` and link it to the Blog:
578
623
 
579
624
  ```ts
625
+ import type { Person, Blog } from 'schema-dts';
626
+
580
627
  const blogId = `${siteUrl}/blog/#blog`;
581
628
 
582
629
  const pieces = [
583
630
  buildWebSite({ url: siteUrl, name: 'My Blog', publisher: { '@id': ids.person } }, ids),
584
- buildPerson({ name: 'Jane Doe', url: aboutUrl, image: { '@id': ids.personImage }, sameAs: [...] }, ids),
631
+ buildPiece<Person>({ '@type': 'Person', '@id': ids.person, name: 'Jane Doe', url: aboutUrl, image: { '@id': ids.personImage }, sameAs: [...] }),
585
632
  buildImageObject({ id: ids.personImage, url: profilePhotoUrl, width: 400, height: 400 }, ids),
586
- buildCustomPiece({
633
+ buildPiece<Blog>({
587
634
  '@type': 'Blog',
588
635
  '@id': blogId,
589
636
  name: 'My Blog',
@@ -599,11 +646,10 @@ const pieces = [
599
646
  dateModified,
600
647
  author: { '@id': ids.person },
601
648
  publisher: { '@id': ids.person },
602
- isPartOf: { '@id': ids.webPage(url) },
649
+ isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }],
603
650
  image: { '@id': ids.primaryImage(url) },
604
651
  articleSection: category,
605
652
  wordCount,
606
- extra: { isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }] },
607
653
  }, ids, 'BlogPosting'),
608
654
  buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
609
655
  buildImageObject({ pageUrl: url, url: featureImageUrl, width: 1200, height: 630 }, ids),
@@ -611,9 +657,9 @@ const pieces = [
611
657
  const graph = assembleGraph(pieces);
612
658
  ```
613
659
 
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.
660
+ **Note:** The `isPartOf` array links the posting to both the `WebPage` and the
661
+ `Blog`. If you don't need the `Blog` link, just use
662
+ `isPartOf: { '@id': ids.webPage(url) }` directly.
617
663
 
618
664
  **Blog listing** (`/blog/`):
619
665
 
@@ -715,14 +761,16 @@ A multi-author blog owned by a company.
715
761
  entities.
716
762
 
717
763
  ```ts
764
+ import type { Organization, Blog, Person } from 'schema-dts';
765
+
718
766
  const ids = makeIds({ siteUrl: 'https://acme.com' });
719
767
 
720
768
  // Site-wide
721
769
  const blogId = 'https://acme.com/blog/#blog';
722
770
  const siteEntities = [
723
- buildOrganization({ slug: 'acme', name: 'Acme Corp', url: 'https://acme.com/', logo: logoUrl, sameAs: [...] }, ids),
771
+ buildPiece<Organization>({ '@type': 'Organization', '@id': ids.organization('acme'), name: 'Acme Corp', url: 'https://acme.com/', logo: logoUrl, sameAs: [...] }),
724
772
  buildWebSite({ url: 'https://acme.com/', name: 'Acme Blog', publisher: { '@id': ids.organization('acme') } }, ids),
725
- buildCustomPiece({
773
+ buildPiece<Blog>({
726
774
  '@type': 'Blog',
727
775
  '@id': blogId,
728
776
  name: 'The Acme Blog',
@@ -735,7 +783,7 @@ const siteEntities = [
735
783
  const authorId = 'https://acme.com/team/jane/#person';
736
784
  const postPieces = [
737
785
  ...siteEntities,
738
- buildCustomPiece({ '@type': 'Person', '@id': authorId, name: 'Jane Doe', url: 'https://acme.com/team/jane/' }),
786
+ buildPiece<Person>({ '@type': 'Person', '@id': authorId, name: 'Jane Doe', url: 'https://acme.com/team/jane/' }),
739
787
  buildWebPage({ url, name: title, isPartOf: { '@id': ids.website }, datePublished }, ids),
740
788
  buildArticle({
741
789
  url,
@@ -744,8 +792,7 @@ const postPieces = [
744
792
  datePublished,
745
793
  author: { '@id': authorId },
746
794
  publisher: { '@id': ids.organization('acme') },
747
- isPartOf: { '@id': ids.webPage(url) },
748
- extra: { isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }] },
795
+ isPartOf: [{ '@id': ids.webPage(url) }, { '@id': blogId }],
749
796
  }, ids, 'BlogPosting'),
750
797
  buildBreadcrumbList({ url, items: [{ name: 'Home', url: siteUrl }, { name: 'Blog', url: blogUrl }, { name: title, url }] }, ids),
751
798
  ];
@@ -755,16 +802,23 @@ const postPieces = [
755
802
 
756
803
  ### E-commerce / product page
757
804
 
758
- Use `buildCustomPiece` for `Product`. The core doesn't have a built-in product
759
- builder because product schemas vary wildly.
805
+ Use `buildPiece<Product>` for `Product` and `buildPiece<ProductGroup>` for `ProductGroup` entities.
760
806
 
761
807
  **Simple product (single variant):**
762
808
 
763
809
  ```ts
810
+ import type { Organization, Product } from 'schema-dts';
811
+
764
812
  const ids = makeIds({ siteUrl: 'https://shop.example.com' });
765
813
 
766
814
  const pieces = [
767
- buildOrganization({ slug: 'shop', name: 'Example Shop', url: siteUrl, logo: logoUrl }, ids),
815
+ buildPiece<Organization>({
816
+ '@type': 'Organization',
817
+ '@id': ids.organization('shop'),
818
+ name: 'Example Shop',
819
+ url: siteUrl,
820
+ logo: logoUrl,
821
+ }),
768
822
  buildWebSite(
769
823
  { url: siteUrl, name: 'Example Shop', publisher: { '@id': ids.organization('shop') } },
770
824
  ids,
@@ -789,13 +843,12 @@ const pieces = [
789
843
  },
790
844
  ids,
791
845
  ),
792
- buildCustomPiece({
846
+ buildPiece<Product>({
793
847
  '@type': 'Product',
794
848
  '@id': `${url}#product`,
795
849
  name: productName,
796
850
  description: productDescription,
797
- image: productImageUrl,
798
- brand: { '@type': 'Brand', name: 'Nike' },
851
+ brand: 'Nike',
799
852
  sku: 'ABC123',
800
853
  offers: {
801
854
  '@type': 'Offer',
@@ -818,6 +871,7 @@ const pieces = [
818
871
  },
819
872
  seller: { '@id': ids.organization('shop') },
820
873
  },
874
+ image: productImageUrl,
821
875
  }),
822
876
  ];
823
877
  ```
@@ -828,6 +882,8 @@ When a product has multiple variants (e.g. sizes, colors), use `ProductGroup`
828
882
  as the parent and individual `Product` entities for each variant:
829
883
 
830
884
  ```ts
885
+ import type { Product, ProductGroup } from 'schema-dts';
886
+
831
887
  const variants = [
832
888
  {
833
889
  sku: 'SHOE-BLK-10',
@@ -857,25 +913,23 @@ const variants = [
857
913
 
858
914
  const pieces = [
859
915
  // ...site-wide + WebPage + BreadcrumbList...
860
- buildCustomPiece({
916
+ buildPiece<ProductGroup>({
861
917
  '@type': 'ProductGroup',
862
- '@id': `${url}#product-group`,
918
+ '@id': `${url}#product`,
863
919
  name: 'Running Shoe',
864
920
  description: productDescription,
865
- brand: { '@type': 'Brand', name: 'Nike' },
921
+ brand: 'Nike',
922
+ url,
866
923
  productGroupID: 'running-shoe',
867
924
  variesBy: ['https://schema.org/color', 'https://schema.org/size'],
868
- hasVariant: variants.map((v) => ({ '@id': `${url}#variant-${v.sku}` })),
925
+ hasVariant: variants.map((v) => ({ '@id': `${url}#product-${v.sku}` })),
869
926
  }),
870
927
  ...variants.map((v) =>
871
- buildCustomPiece({
928
+ buildPiece<Product>({
872
929
  '@type': 'Product',
873
- '@id': `${url}#variant-${v.sku}`,
930
+ '@id': `${url}#product-${v.sku}`,
874
931
  name: v.name,
875
932
  sku: v.sku,
876
- color: v.color,
877
- size: v.size,
878
- image: [productImageUrl],
879
933
  offers: {
880
934
  '@type': 'Offer',
881
935
  price: v.price,
@@ -896,6 +950,9 @@ const pieces = [
896
950
  shippingDestination: { '@type': 'DefinedRegion', addressCountry: 'US' },
897
951
  },
898
952
  },
953
+ color: v.color,
954
+ size: v.size,
955
+ image: [productImageUrl],
899
956
  }),
900
957
  ),
901
958
  ];
@@ -913,48 +970,43 @@ import type { Restaurant } from 'schema-dts';
913
970
  const ids = makeIds({ siteUrl: 'https://chezexample.com' });
914
971
 
915
972
  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
- },
973
+ buildPiece<Restaurant>({
974
+ '@type': 'Restaurant',
975
+ '@id': ids.organization('chez-example'),
976
+ name: 'Chez Example',
977
+ url: 'https://chezexample.com/',
978
+ logo: logoUrl,
979
+ sameAs: ['https://instagram.com/chezexample'],
980
+ address: {
981
+ '@type': 'PostalAddress',
982
+ streetAddress: '123 Rue de la Paix',
983
+ addressLocality: 'Paris',
984
+ postalCode: '75002',
985
+ addressCountry: 'FR',
954
986
  },
955
- ids,
956
- 'Restaurant',
957
- ),
987
+ telephone: '+33-1-23-45-67-89',
988
+ priceRange: '$$$',
989
+ servesCuisine: 'French',
990
+ geo: {
991
+ '@type': 'GeoCoordinates',
992
+ latitude: 48.8698,
993
+ longitude: 2.3311,
994
+ },
995
+ openingHoursSpecification: [
996
+ {
997
+ '@type': 'OpeningHoursSpecification',
998
+ dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
999
+ opens: '12:00',
1000
+ closes: '14:30',
1001
+ },
1002
+ {
1003
+ '@type': 'OpeningHoursSpecification',
1004
+ dayOfWeek: ['Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
1005
+ opens: '19:00',
1006
+ closes: '22:30',
1007
+ },
1008
+ ],
1009
+ }),
958
1010
  buildWebSite(
959
1011
  {
960
1012
  url: siteUrl,
@@ -981,20 +1033,21 @@ const pieces = [
981
1033
  A freelancer or agency showcasing work.
982
1034
 
983
1035
  ```ts
1036
+ import type { Person } from 'schema-dts';
1037
+
984
1038
  const ids = makeIds({ siteUrl: 'https://janedoe.design' });
985
1039
 
986
1040
  // Homepage — CollectionPage showcasing work
987
1041
  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
- ),
1042
+ buildPiece<Person>({
1043
+ '@type': 'Person',
1044
+ '@id': ids.person,
1045
+ name: 'Jane Doe',
1046
+ jobTitle: 'Product Designer',
1047
+ url: siteUrl,
1048
+ image: { '@id': ids.personImage },
1049
+ sameAs: [dribbble, linkedin],
1050
+ }),
998
1051
  buildImageObject({ id: ids.personImage, url: headshot, width: 400, height: 400 }, ids),
999
1052
  buildWebSite({ url: siteUrl, name: 'Jane Doe Design', publisher: { '@id': ids.person } }, ids),
1000
1053
  buildWebPage(
@@ -1055,13 +1108,18 @@ const projectPieces = [
1055
1108
  A docs site for a software project or API.
1056
1109
 
1057
1110
  ```ts
1111
+ import type { Organization } from 'schema-dts';
1112
+
1058
1113
  const ids = makeIds({ siteUrl: 'https://docs.example.com' });
1059
1114
 
1060
1115
  const pieces = [
1061
- buildOrganization(
1062
- { slug: 'example', name: 'Example Inc', url: 'https://example.com/', logo: logoUrl },
1063
- ids,
1064
- ),
1116
+ buildPiece<Organization>({
1117
+ '@type': 'Organization',
1118
+ '@id': ids.organization('example'),
1119
+ name: 'Example Inc',
1120
+ url: 'https://example.com/',
1121
+ logo: logoUrl,
1122
+ }),
1065
1123
  buildWebSite(
1066
1124
  {
1067
1125
  url: siteUrl,
@@ -1109,15 +1167,23 @@ container for `PodcastEpisode`. Include the series as a site-wide entity.
1109
1167
  **Video podcast (YouTube-based):**
1110
1168
 
1111
1169
  ```ts
1170
+ import type { Person, PodcastSeries } from 'schema-dts';
1171
+
1112
1172
  const ids = makeIds({ siteUrl: 'https://podcast.example.com' });
1113
1173
  const seriesId = `${siteUrl}#podcast-series`;
1114
1174
 
1115
1175
  // Episode page
1116
1176
  const pieces = [
1117
- buildPerson({ name: 'Host Name', url: aboutUrl, image: { '@id': ids.personImage } }, ids),
1177
+ buildPiece<Person>({
1178
+ '@type': 'Person',
1179
+ '@id': ids.person,
1180
+ name: 'Host Name',
1181
+ url: aboutUrl,
1182
+ image: { '@id': ids.personImage },
1183
+ }),
1118
1184
  buildImageObject({ id: ids.personImage, url: hostPhotoUrl, width: 400, height: 400 }, ids),
1119
1185
  buildWebSite({ url: siteUrl, name: 'My Podcast', publisher: { '@id': ids.person } }, ids),
1120
- buildCustomPiece({
1186
+ buildPiece<PodcastSeries>({
1121
1187
  '@type': 'PodcastSeries',
1122
1188
  '@id': seriesId,
1123
1189
  name: 'My Podcast',
@@ -1170,12 +1236,14 @@ const pieces = [
1170
1236
  Use `PodcastEpisode` linked to the `PodcastSeries`:
1171
1237
 
1172
1238
  ```ts
1239
+ import type { PodcastEpisode } from 'schema-dts';
1240
+
1173
1241
  const seriesId = `${siteUrl}#podcast-series`;
1174
1242
 
1175
1243
  const pieces = [
1176
1244
  // ...site-wide entities including PodcastSeries...
1177
1245
  buildWebPage({ url, name: episodeTitle, isPartOf: { '@id': ids.website }, datePublished }, ids),
1178
- buildCustomPiece({
1246
+ buildPiece<PodcastEpisode>({
1179
1247
  '@type': 'PodcastEpisode',
1180
1248
  '@id': `${url}#episode`,
1181
1249
  name: episodeTitle,
@@ -1225,10 +1293,12 @@ const pieces = [
1225
1293
  ### Vacation rental / accommodation
1226
1294
 
1227
1295
  ```ts
1296
+ import type { Person, VacationRental } from 'schema-dts';
1297
+
1228
1298
  const ids = makeIds({ siteUrl: 'https://myhouse.example.com' });
1229
1299
 
1230
1300
  const pieces = [
1231
- buildPerson({ name: 'Owner Name', url: siteUrl }, ids),
1301
+ buildPiece<Person>({ '@type': 'Person', '@id': ids.person, name: 'Owner Name', url: siteUrl }),
1232
1302
  buildWebSite({ url: siteUrl, name: 'Villa Example', publisher: { '@id': ids.person } }, ids),
1233
1303
  buildWebPage(
1234
1304
  {
@@ -1238,7 +1308,7 @@ const pieces = [
1238
1308
  },
1239
1309
  ids,
1240
1310
  ),
1241
- buildCustomPiece({
1311
+ buildPiece<VacationRental>({
1242
1312
  '@type': 'VacationRental',
1243
1313
  '@id': `${siteUrl}#rental`,
1244
1314
  name: 'Villa Example',
@@ -1289,6 +1359,8 @@ const pieces = [
1289
1359
  ### Recipe site
1290
1360
 
1291
1361
  ```ts
1362
+ import type { Recipe } from 'schema-dts';
1363
+
1292
1364
  const ids = makeIds({ siteUrl: 'https://recipes.example.com' });
1293
1365
 
1294
1366
  const pieces = [
@@ -1314,14 +1386,11 @@ const pieces = [
1314
1386
  },
1315
1387
  ids,
1316
1388
  ),
1317
- buildCustomPiece({
1389
+ buildPiece<Recipe>({
1318
1390
  '@type': 'Recipe',
1319
1391
  '@id': `${url}#recipe`,
1320
1392
  name: recipeName,
1321
- description: recipeDescription,
1322
- image: recipeImageUrl,
1323
1393
  author: { '@id': ids.person },
1324
- datePublished: publishDate.toISOString(),
1325
1394
  prepTime: 'PT15M',
1326
1395
  cookTime: 'PT45M',
1327
1396
  totalTime: 'PT1H',
@@ -1344,6 +1413,9 @@ const pieces = [
1344
1413
  { '@type': 'HowToStep', text: 'Mix egg yolks with pecorino.' },
1345
1414
  { '@type': 'HowToStep', text: 'Combine and serve immediately.' },
1346
1415
  ],
1416
+ description: recipeDescription,
1417
+ image: recipeImageUrl,
1418
+ datePublished: publishDate.toISOString(),
1347
1419
  }),
1348
1420
  ];
1349
1421
  ```
@@ -1353,7 +1425,9 @@ const pieces = [
1353
1425
  ### Event page
1354
1426
 
1355
1427
  ```ts
1356
- buildCustomPiece({
1428
+ import type { Event } from 'schema-dts';
1429
+
1430
+ buildPiece<Event>({
1357
1431
  '@type': 'Event',
1358
1432
  '@id': `${url}#event`,
1359
1433
  name: 'JavaScript Conference 2026',
@@ -1389,8 +1463,16 @@ buildCustomPiece({
1389
1463
  ### SaaS / software product landing page
1390
1464
 
1391
1465
  ```ts
1466
+ import type { Organization, SoftwareApplication } from 'schema-dts';
1467
+
1392
1468
  const pieces = [
1393
- buildOrganization({ slug: 'myapp', name: 'MyApp Inc', url: siteUrl, logo: logoUrl }, ids),
1469
+ buildPiece<Organization>({
1470
+ '@type': 'Organization',
1471
+ '@id': ids.organization('myapp'),
1472
+ name: 'MyApp Inc',
1473
+ url: siteUrl,
1474
+ logo: logoUrl,
1475
+ }),
1394
1476
  buildWebSite(
1395
1477
  { url: siteUrl, name: 'MyApp', publisher: { '@id': ids.organization('myapp') } },
1396
1478
  ids,
@@ -1403,7 +1485,7 @@ const pieces = [
1403
1485
  },
1404
1486
  ids,
1405
1487
  ),
1406
- buildCustomPiece({
1488
+ buildPiece<SoftwareApplication>({
1407
1489
  '@type': 'SoftwareApplication',
1408
1490
  '@id': `${siteUrl}#app`,
1409
1491
  name: 'MyApp',
@@ -1443,13 +1525,15 @@ const pieces = [
1443
1525
  Combine `WebPage` with a `FAQPage` custom piece:
1444
1526
 
1445
1527
  ```ts
1528
+ import type { FAQPage } from 'schema-dts';
1529
+
1446
1530
  const pieces = [
1447
1531
  // ...site-wide entities...
1448
1532
  buildWebPage(
1449
1533
  { url, name: 'Frequently Asked Questions', isPartOf: { '@id': ids.website } },
1450
1534
  ids,
1451
1535
  ),
1452
- buildCustomPiece({
1536
+ buildPiece<FAQPage>({
1453
1537
  '@type': 'FAQPage',
1454
1538
  '@id': `${url}#faq`,
1455
1539
  mainEntity: [
@@ -1479,7 +1563,9 @@ const pieces = [
1479
1563
  ### Course / educational content
1480
1564
 
1481
1565
  ```ts
1482
- buildCustomPiece({
1566
+ import type { Course } from 'schema-dts';
1567
+
1568
+ buildPiece<Course>({
1483
1569
  '@type': 'Course',
1484
1570
  '@id': `${url}#course`,
1485
1571
  name: 'Introduction to TypeScript',
@@ -1534,17 +1620,19 @@ policies. It can be applied to `Organization`, `Person`, or `CreativeWork`
1534
1620
  search engines and AI agents about your content's credibility.
1535
1621
 
1536
1622
  ```ts
1623
+ import type { Person, Blog, Organization } from 'schema-dts';
1624
+
1537
1625
  // On a Person entity (personal blog)
1538
- buildPerson({
1626
+ buildPiece<Person>({
1627
+ '@type': 'Person',
1628
+ '@id': ids.person,
1539
1629
  name: 'Jane Doe',
1540
1630
  url: aboutUrl,
1541
- extra: {
1542
- publishingPrinciples: `${siteUrl}/editorial-policy/`,
1543
- },
1544
- }, ids),
1631
+ publishingPrinciples: `${siteUrl}/editorial-policy/`,
1632
+ }),
1545
1633
 
1546
1634
  // On a Blog entity
1547
- buildCustomPiece({
1635
+ buildPiece<Blog>({
1548
1636
  '@type': 'Blog',
1549
1637
  '@id': blogId,
1550
1638
  name: 'My Blog',
@@ -1554,13 +1642,12 @@ buildCustomPiece({
1554
1642
  }),
1555
1643
 
1556
1644
  // On an Organization (news site, company blog)
1557
- buildOrganization({
1558
- slug: 'newsroom',
1645
+ buildPiece<Organization>({
1646
+ '@type': 'Organization',
1647
+ '@id': ids.organization('newsroom'),
1559
1648
  name: 'The Daily Example',
1560
- extra: {
1561
- publishingPrinciples: `${siteUrl}/ethics/`,
1562
- },
1563
- }, ids),
1649
+ publishingPrinciples: `${siteUrl}/ethics/`,
1650
+ }),
1564
1651
  ```
1565
1652
 
1566
1653
  ### Specialized policy sub-properties
@@ -1569,21 +1656,22 @@ For news and media organizations, schema.org has more specific sub-properties
1569
1656
  of `publishingPrinciples`:
1570
1657
 
1571
1658
  ```ts
1572
- buildOrganization({
1573
- slug: 'newsroom',
1659
+ import type { Organization } from 'schema-dts';
1660
+
1661
+ buildPiece<Organization>({
1662
+ '@type': 'Organization',
1663
+ '@id': ids.organization('newsroom'),
1574
1664
  name: 'The Daily Example',
1575
1665
  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),
1666
+ publishingPrinciples: `${siteUrl}/editorial-policy/`,
1667
+ correctionsPolicy: `${siteUrl}/corrections/`,
1668
+ verificationFactCheckingPolicy: `${siteUrl}/fact-checking/`,
1669
+ actionableFeedbackPolicy: `${siteUrl}/feedback/`,
1670
+ unnamedSourcesPolicy: `${siteUrl}/sources-policy/`,
1671
+ ownershipFundingInfo: `${siteUrl}/about/ownership/`,
1672
+ diversityStaffingReport: `${siteUrl}/diversity-report/`,
1673
+ masthead: `${siteUrl}/team/`,
1674
+ }),
1587
1675
  ```
1588
1676
 
1589
1677
  ### When to use which
@@ -1621,9 +1709,7 @@ buildWebPage({
1621
1709
  copyrightNotice: '© 2026 Jane Doe. All rights reserved.',
1622
1710
  license: 'https://creativecommons.org/licenses/by/4.0/',
1623
1711
  isAccessibleForFree: true,
1624
- extra: {
1625
- creditText: 'Jane Doe / janedoe.com',
1626
- },
1712
+ creditText: 'Jane Doe / janedoe.com',
1627
1713
  }, ids),
1628
1714
  ```
1629
1715
 
@@ -1634,11 +1720,9 @@ buildArticle({
1634
1720
  url,
1635
1721
  headline: title,
1636
1722
  // ...other article properties...
1637
- extra: {
1638
- copyrightHolder: { '@id': ids.person },
1639
- copyrightYear: 2026,
1640
- license: 'https://creativecommons.org/licenses/by-sa/4.0/',
1641
- },
1723
+ copyrightHolder: { '@id': ids.person },
1724
+ copyrightYear: 2026,
1725
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/',
1642
1726
  }, ids, 'BlogPosting'),
1643
1727
  ```
1644
1728
 
@@ -1771,7 +1855,9 @@ potentialAction: {
1771
1855
  Add to the `Product` or `ProductGroup` entity:
1772
1856
 
1773
1857
  ```ts
1774
- buildCustomPiece({
1858
+ import type { Product } from 'schema-dts';
1859
+
1860
+ buildPiece<Product>({
1775
1861
  '@type': 'Product',
1776
1862
  '@id': `${url}#product`,
1777
1863
  name: productName,
@@ -1824,7 +1910,9 @@ potentialAction: {
1824
1910
  Add to the `VacationRental`, `Product`, or `Car` entity:
1825
1911
 
1826
1912
  ```ts
1827
- buildCustomPiece({
1913
+ import type { VacationRental } from 'schema-dts';
1914
+
1915
+ buildPiece<VacationRental>({
1828
1916
  '@type': 'VacationRental',
1829
1917
  '@id': `${siteUrl}#rental`,
1830
1918
  name: 'Villa Example',
@@ -1933,29 +2021,29 @@ Many entities benefit from multiple actions. A WebSite typically has a
1933
2021
  `SearchAction`; the entities within it have trade actions:
1934
2022
 
1935
2023
  ```ts
2024
+ import type { Product } from 'schema-dts';
2025
+
1936
2026
  // WebSite: how to search
1937
2027
  buildWebSite({
1938
2028
  url: siteUrl,
1939
2029
  name: 'My Shop',
1940
2030
  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
- },
2031
+ potentialAction: {
2032
+ '@type': 'SearchAction',
2033
+ target: {
2034
+ '@type': 'EntryPoint',
2035
+ urlTemplate: `${siteUrl}search?q={search_term_string}`,
2036
+ },
2037
+ 'query-input': {
2038
+ '@type': 'PropertyValueSpecification',
2039
+ valueRequired: true,
2040
+ valueName: 'search_term_string',
1953
2041
  },
1954
2042
  },
1955
2043
  }, ids),
1956
2044
 
1957
2045
  // Product: how to buy
1958
- buildCustomPiece({
2046
+ buildPiece<Product>({
1959
2047
  '@type': 'Product',
1960
2048
  '@id': `${url}#product`,
1961
2049
  name: productName,
@@ -1994,7 +2082,7 @@ An entity can have multiple `@type` values. This is useful when an entity
1994
2082
  legitimately belongs to more than one type:
1995
2083
 
1996
2084
  ```ts
1997
- buildCustomPiece({
2085
+ buildPiece({
1998
2086
  '@type': ['Organization', 'Brand'],
1999
2087
  '@id': ids.organization('acme'),
2000
2088
  name: 'Acme',
@@ -2015,18 +2103,15 @@ Common multi-type combinations:
2015
2103
  - `['WebPage', 'ItemPage']` — Product detail pages
2016
2104
  - `['WebPage', 'FAQPage']` — FAQ pages (alternative to separate FAQPage entity)
2017
2105
 
2018
- **Note:** When using multi-type with `buildOrganization`, pass the primary
2019
- subtype as the third argument and add additional types via `extra`:
2106
+ **Note:** With `buildPiece`, pass the `@type` array directly:
2020
2107
 
2021
2108
  ```ts
2022
- buildOrganization(
2023
- {
2024
- slug: 'acme',
2025
- name: 'Acme',
2026
- extra: { '@type': ['Organization', 'Brand'] },
2027
- },
2028
- ids,
2029
- );
2109
+ buildPiece({
2110
+ '@type': ['Organization', 'Brand'],
2111
+ '@id': ids.organization('acme'),
2112
+ name: 'Acme',
2113
+ url: 'https://acme.com/',
2114
+ });
2030
2115
  ```
2031
2116
 
2032
2117
  ---
@@ -2037,49 +2122,47 @@ For established businesses, a richer Organization entity improves knowledge
2037
2122
  graph representation. Here's the full pattern:
2038
2123
 
2039
2124
  ```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
- },
2125
+ import type { Organization } from 'schema-dts';
2126
+
2127
+ buildPiece<Organization>({
2128
+ '@type': 'Organization',
2129
+ '@id': ids.organization('acme'),
2130
+ name: 'Acme Corp',
2131
+ url: 'https://acme.com/',
2132
+ logo: 'https://acme.com/logo.png',
2133
+ description: 'We build developer tools.',
2134
+ sameAs: [
2135
+ 'https://twitter.com/acme',
2136
+ 'https://linkedin.com/company/acme',
2137
+ 'https://github.com/acme',
2138
+ 'https://en.wikipedia.org/wiki/Acme_Corp',
2139
+ ],
2140
+ legalName: 'Acme Corp B.V.',
2141
+ foundingDate: '2015-03-01',
2142
+ founder: {
2143
+ '@type': 'Person',
2144
+ name: 'Jane Doe',
2145
+ sameAs: 'https://en.wikipedia.org/wiki/Jane_Doe',
2080
2146
  },
2081
- ids,
2082
- );
2147
+ numberOfEmployees: 45,
2148
+ slogan: 'Tools for the modern web',
2149
+ parentOrganization: {
2150
+ '@type': 'Organization',
2151
+ name: 'Parent Holdings Inc',
2152
+ url: 'https://parent.com/',
2153
+ },
2154
+ memberOf: {
2155
+ '@type': 'Organization',
2156
+ name: 'World Wide Web Consortium (W3C)',
2157
+ url: 'https://w3.org/',
2158
+ },
2159
+ address: {
2160
+ '@type': 'PostalAddress',
2161
+ streetAddress: '123 Tech Lane',
2162
+ addressLocality: 'Amsterdam',
2163
+ addressCountry: 'NL',
2164
+ },
2165
+ });
2083
2166
  ```
2084
2167
 
2085
2168
  Include as much as is factually accurate. Don't fabricate data. Properties like
@@ -2094,63 +2177,57 @@ For personal sites, a detailed Person entity establishes identity and
2094
2177
  credibility. jonoalderson.com uses 80+ entities. Here's the extended pattern:
2095
2178
 
2096
2179
  ```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',
2180
+ import type { Person } from 'schema-dts';
2181
+
2182
+ buildPiece<Person>({
2183
+ '@type': 'Person',
2184
+ '@id': ids.person,
2185
+ name: 'Jane Doe',
2186
+ familyName: 'Doe',
2187
+ birthDate: '1990-01-15',
2188
+ gender: 'female',
2189
+ nationality: { '@id': ids.country('US') },
2190
+ description: 'Software engineer and technical writer.',
2191
+ jobTitle: 'Lead Engineer',
2192
+ knowsLanguage: ['en', 'es', 'pt'],
2193
+ url: 'https://janedoe.com/about/',
2194
+ image: { '@id': ids.personImage },
2195
+ sameAs: [
2196
+ 'https://twitter.com/janedoe',
2197
+ 'https://github.com/janedoe',
2198
+ 'https://linkedin.com/in/janedoe',
2199
+ 'https://bsky.app/profile/janedoe.com',
2200
+ 'https://mastodon.social/@janedoe',
2201
+ 'https://en.wikipedia.org/wiki/Jane_Doe',
2202
+ ],
2203
+ worksFor: [
2204
+ {
2205
+ '@type': 'EmployeeRole',
2206
+ roleName: 'Lead Engineer',
2207
+ startDate: '2022-01',
2208
+ worksFor: { '@id': ids.organization('acme') },
2135
2209
  },
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'],
2210
+ {
2211
+ '@type': 'EmployeeRole',
2212
+ roleName: 'Advisor',
2213
+ startDate: '2024-06',
2214
+ worksFor: { '@id': ids.organization('startup') },
2150
2215
  },
2216
+ ],
2217
+ spouse: {
2218
+ '@type': 'Person',
2219
+ '@id': `${siteUrl}/#/schema.org/Person/john`,
2220
+ name: 'John Doe',
2151
2221
  },
2152
- ids,
2153
- );
2222
+ knowsAbout: ['TypeScript', 'Schema.org', 'Search Engine Optimization', 'Web Performance'],
2223
+ honorificPrefix: 'Dr.',
2224
+ alumniOf: {
2225
+ '@type': 'EducationalOrganization',
2226
+ name: 'MIT',
2227
+ url: 'https://mit.edu/',
2228
+ },
2229
+ award: ['Best Developer Blog 2025', 'Open Source Contributor of the Year 2024'],
2230
+ });
2154
2231
  ```
2155
2232
 
2156
2233
  **Practical advice:**
@@ -2533,13 +2610,14 @@ import {
2533
2610
  makeIds,
2534
2611
  assembleGraph,
2535
2612
  buildWebSite,
2536
- buildPerson,
2613
+ buildPiece,
2537
2614
  buildWebPage,
2538
2615
  buildArticle,
2539
2616
  buildBreadcrumbList,
2540
2617
  buildImageObject,
2541
2618
  buildSiteNavigationElement,
2542
2619
  } from '@jdevalk/seo-graph-core';
2620
+ import type { Person } from 'schema-dts';
2543
2621
 
2544
2622
  const SITE_URL = 'https://example.com';
2545
2623
  export const ids = makeIds({ siteUrl: SITE_URL, personUrl: `${SITE_URL}/about/` });
@@ -2551,15 +2629,14 @@ function siteWideEntities() {
2551
2629
  { url: `${SITE_URL}/`, name: 'My Blog', publisher: { '@id': ids.person } },
2552
2630
  ids,
2553
2631
  ),
2554
- buildPerson(
2555
- {
2556
- name: 'Jane Doe',
2557
- url: `${SITE_URL}/about/`,
2558
- image: { '@id': ids.personImage },
2559
- sameAs: ['...'],
2560
- },
2561
- ids,
2562
- ),
2632
+ buildPiece<Person>({
2633
+ '@type': 'Person',
2634
+ '@id': ids.person,
2635
+ name: 'Jane Doe',
2636
+ url: `${SITE_URL}/about/`,
2637
+ image: { '@id': ids.personImage },
2638
+ sameAs: ['...'],
2639
+ }),
2563
2640
  buildImageObject(
2564
2641
  { id: ids.personImage, url: `${SITE_URL}/jane.jpg`, width: 400, height: 400 },
2565
2642
  ids,
@@ -2795,70 +2872,76 @@ export const GET = createSchemaMap({
2795
2872
  When a person works for several companies, create an organization for each:
2796
2873
 
2797
2874
  ```ts
2875
+ import type { Organization, Person } from 'schema-dts';
2876
+
2798
2877
  const orgs = [
2799
2878
  { slug: 'acme', name: 'Acme Corp', url: 'https://acme.com/' },
2800
2879
  { slug: 'side-project', name: 'Side Project Inc', url: 'https://sideproject.com/' },
2801
2880
  ];
2802
2881
 
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,
2882
+ const orgPieces = orgs.map((org) =>
2883
+ buildPiece<Organization>({
2884
+ '@type': 'Organization',
2885
+ '@id': ids.organization(org.slug),
2886
+ name: org.name,
2887
+ url: org.url,
2888
+ }),
2824
2889
  );
2890
+
2891
+ const personPiece = buildPiece<Person>({
2892
+ '@type': 'Person',
2893
+ '@id': ids.person,
2894
+ name: 'Jane Doe',
2895
+ worksFor: [
2896
+ {
2897
+ '@type': 'EmployeeRole',
2898
+ roleName: 'CEO',
2899
+ startDate: '2020',
2900
+ worksFor: { '@id': ids.organization('acme') },
2901
+ },
2902
+ {
2903
+ '@type': 'EmployeeRole',
2904
+ roleName: 'Advisor',
2905
+ startDate: '2023',
2906
+ worksFor: { '@id': ids.organization('side-project') },
2907
+ },
2908
+ ],
2909
+ });
2825
2910
  ```
2826
2911
 
2827
2912
  ### Organization subtypes
2828
2913
 
2829
- Use `schema-dts` generics for full type safety on subtypes:
2914
+ Use the schema.org subtype directly as the `buildPiece` generic for full type safety:
2830
2915
 
2831
2916
  ```ts
2832
- import type { Dentist, Hotel, EducationalOrganization } from 'schema-dts';
2917
+ import type { Dentist, Hotel } from 'schema-dts';
2833
2918
 
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
- );
2919
+ buildPiece<Dentist>({
2920
+ '@type': 'Dentist',
2921
+ '@id': ids.organization('clinic'),
2922
+ name: 'Smile Dental',
2923
+ medicalSpecialty: 'Dentistry',
2924
+ });
2925
+ buildPiece<Hotel>({
2926
+ '@type': 'Hotel',
2927
+ '@id': ids.organization('hotel'),
2928
+ name: 'Grand Hotel',
2929
+ starRating: { '@type': 'Rating', ratingValue: 4 },
2930
+ checkinTime: '15:00',
2931
+ checkoutTime: '11:00',
2932
+ });
2852
2933
  ```
2853
2934
 
2854
2935
  ### Multi-author blogs
2855
2936
 
2856
- When different posts have different authors, use `buildCustomPiece` for author
2857
- Person entities (since `buildPerson` always uses `ids.person` as the `@id`):
2937
+ When different posts have different authors, use `buildPiece<Person>` with a
2938
+ custom `@id` for each author (reserving `ids.person` for the site-wide person):
2858
2939
 
2859
2940
  ```ts
2941
+ import type { Person } from 'schema-dts';
2942
+
2860
2943
  const authorId = `${siteUrl}/authors/${authorSlug}/#person`;
2861
- const authorPiece = buildCustomPiece({
2944
+ const authorPiece = buildPiece<Person>({
2862
2945
  '@type': 'Person',
2863
2946
  '@id': authorId,
2864
2947
  name: authorName,