diff --git a/.changeset/article-ispartof-array.md b/.changeset/article-ispartof-array.md new file mode 100644 index 0000000..0715cf6 --- /dev/null +++ b/.changeset/article-ispartof-array.md @@ -0,0 +1,14 @@ +--- +'@jdevalk/seo-graph-core': minor +--- + +**`buildArticle` now accepts an array for `isPartOf`.** + +`ArticleInput.isPartOf` was typed as a single `Reference`, but the shipped +"Personal blog" recipe in `AGENTS.md` links a posting to both its `WebPage` +and the `Blog` via `isPartOf: [{ '@id': webPage }, { '@id': blog }]`. The +builder already emitted the value verbatim at runtime, so the array worked — +but the type rejected it, forcing callers to add an `as` cast. + +The input type is now `Reference | Reference[]`. No runtime change; existing +single-reference callers are unaffected. diff --git a/.changeset/builders-return-graphentity.md b/.changeset/builders-return-graphentity.md new file mode 100644 index 0000000..da9a9d4 --- /dev/null +++ b/.changeset/builders-return-graphentity.md @@ -0,0 +1,18 @@ +--- +'@jdevalk/seo-graph-core': minor +--- + +**Piece builders now return `GraphEntity` instead of `Record`.** + +`assembleGraph(pieces)` requires each piece to be a +`GraphEntity`, whose `@type` is required. The builders (`buildWebSite`, +`buildWebPage`, `buildArticle`, `buildBreadcrumbList`, `buildImageObject`, +`buildVideoObject`, `buildSiteNavigationElement`, `buildPiece`) were declared +to return `Record`, which lacks `@type` — so the documented +pattern `assembleGraph([buildWebSite(...), buildArticle(...)])` failed `tsc` / +`astro check` under strict mode and forced callers to cast `as GraphEntity[]`. + +Every builder already produces an object with a literal `@type`, so the return +type is widened to `GraphEntity` with no runtime change. Builder results — and +the arrays returned by `aggregate`/`createSchemaEndpoint` mappers — now compose +with `assembleGraph` without a cast. diff --git a/packages/seo-graph-core/src/pieces/article.ts b/packages/seo-graph-core/src/pieces/article.ts index e60820e..0d2c823 100644 --- a/packages/seo-graph-core/src/pieces/article.ts +++ b/packages/seo-graph-core/src/pieces/article.ts @@ -1,7 +1,7 @@ import type { ArticleLeaf } from 'schema-dts'; import type { IdFactory } from '../ids.js'; -import type { Reference, CreativeWorkFields } from '../types.js'; +import type { Reference, CreativeWorkFields, GraphEntity } from '../types.js'; import { applyCreativeWorkFields, spreadRemainingProperties, @@ -25,8 +25,13 @@ export type ArticleType = interface ArticleCoreFields extends CreativeWorkFields { /** Canonical URL of the article's page. The @id is `${url}#article`. */ url: string; - /** Reference to the enclosing WebPage (usually ids.webPage(url)). */ - isPartOf: Reference; + /** + * Reference to the enclosing entity, usually the WebPage + * (`ids.webPage(url)`). Pass an array to link the Article to more than + * one parent — e.g. both its WebPage and a Blog + * (`[{ '@id': ids.webPage(url) }, { '@id': blogId }]`). + */ + isPartOf: Reference | Reference[]; /** Author reference. May include a `name` alongside the `@id`. */ author: Reference; /** Publisher reference. Usually the same as the author for personal blogs. */ @@ -67,8 +72,8 @@ export function buildArticle( input: ArticleInput, ids: IdFactory, type: ArticleType = 'Article', -): Record { - const piece: Record = { +): GraphEntity { + const piece: GraphEntity = { '@type': type, '@id': ids.article(input.url), isPartOf: input.isPartOf, diff --git a/packages/seo-graph-core/src/pieces/breadcrumb.ts b/packages/seo-graph-core/src/pieces/breadcrumb.ts index a7fa01f..bba4c60 100644 --- a/packages/seo-graph-core/src/pieces/breadcrumb.ts +++ b/packages/seo-graph-core/src/pieces/breadcrumb.ts @@ -1,6 +1,7 @@ import type { BreadcrumbListLeaf } from 'schema-dts'; import type { IdFactory } from '../ids.js'; +import type { GraphEntity } from '../types.js'; import { spreadRemainingProperties } from '../types.js'; export interface BreadcrumbItem { @@ -27,10 +28,7 @@ const HANDLED_KEYS = new Set(['url', 'items']); /** * Build a schema.org BreadcrumbList piece. */ -export function buildBreadcrumbList( - input: BreadcrumbListInput, - ids: IdFactory, -): Record { +export function buildBreadcrumbList(input: BreadcrumbListInput, ids: IdFactory): GraphEntity { const lastIndex = input.items.length - 1; const itemListElement = input.items.map((item, index) => ({ '@type': 'ListItem', @@ -43,7 +41,7 @@ export function buildBreadcrumbList( : item.url, })); - const piece: Record = { + const piece: GraphEntity = { '@type': 'BreadcrumbList', '@id': ids.breadcrumb(input.url), itemListElement, diff --git a/packages/seo-graph-core/src/pieces/custom.ts b/packages/seo-graph-core/src/pieces/custom.ts index 545efec..7e72def 100644 --- a/packages/seo-graph-core/src/pieces/custom.ts +++ b/packages/seo-graph-core/src/pieces/custom.ts @@ -1,5 +1,7 @@ import type { Thing } from 'schema-dts'; +import type { GraphEntity } from '../types.js'; + /** * Build an arbitrary schema.org piece from a raw object. * @@ -26,13 +28,15 @@ export function buildPiece( '@type': TType; '@id'?: string; }, -): Record; +): GraphEntity; export function buildPiece( raw: Record & { '@type': string | readonly string[]; '@id'?: string; }, -): Record; -export function buildPiece(raw: Record): Record { - return raw; +): GraphEntity; +export function buildPiece(raw: Record): GraphEntity { + // The public overloads both require `@type` on `raw`, so the result + // always satisfies GraphEntity; the implementation signature is wider. + return raw as GraphEntity; } diff --git a/packages/seo-graph-core/src/pieces/image.ts b/packages/seo-graph-core/src/pieces/image.ts index 092ee09..92b5abf 100644 --- a/packages/seo-graph-core/src/pieces/image.ts +++ b/packages/seo-graph-core/src/pieces/image.ts @@ -1,6 +1,7 @@ import type { ImageObjectLeaf } from 'schema-dts'; import type { IdFactory } from '../ids.js'; +import type { GraphEntity } from '../types.js'; import { spreadRemainingProperties } from '../types.js'; interface ImageObjectCoreFields { @@ -32,14 +33,14 @@ const HANDLED_KEYS = new Set([ * primary image (id = `${pageUrl}#primaryimage`), or `id` for a site- * wide image like a personal logo. */ -export function buildImageObject(input: ImageObjectInput, ids: IdFactory): Record { +export function buildImageObject(input: ImageObjectInput, ids: IdFactory): GraphEntity { const resolvedId = input.id ?? (input.pageUrl !== undefined ? ids.primaryImage(input.pageUrl) : undefined); if (resolvedId === undefined) { throw new Error('buildImageObject: either `id` or `pageUrl` is required'); } - const piece: Record = { + const piece: GraphEntity = { '@type': 'ImageObject', '@id': resolvedId, url: input.url, diff --git a/packages/seo-graph-core/src/pieces/navigation.ts b/packages/seo-graph-core/src/pieces/navigation.ts index f7cab34..d9ed558 100644 --- a/packages/seo-graph-core/src/pieces/navigation.ts +++ b/packages/seo-graph-core/src/pieces/navigation.ts @@ -1,7 +1,7 @@ import type { SiteNavigationElementLeaf } from 'schema-dts'; import type { IdFactory } from '../ids.js'; -import type { Reference } from '../types.js'; +import type { Reference, GraphEntity } from '../types.js'; import { spreadRemainingProperties } from '../types.js'; export interface NavigationItem { @@ -27,14 +27,14 @@ const HANDLED_KEYS = new Set(['name', 'isPartOf', 'items']); export function buildSiteNavigationElement( input: SiteNavigationInput, ids: IdFactory, -): Record { +): GraphEntity { const hasPart = input.items.map((item) => ({ '@type': 'SiteNavigationElement', name: item.name, url: item.url, })); - const piece: Record = { + const piece: GraphEntity = { '@type': 'SiteNavigationElement', '@id': ids.navigation, name: input.name, diff --git a/packages/seo-graph-core/src/pieces/video.ts b/packages/seo-graph-core/src/pieces/video.ts index d3840ad..b5eb92f 100644 --- a/packages/seo-graph-core/src/pieces/video.ts +++ b/packages/seo-graph-core/src/pieces/video.ts @@ -1,7 +1,7 @@ import type { VideoObjectLeaf } from 'schema-dts'; import type { IdFactory } from '../ids.js'; -import type { Reference } from '../types.js'; +import type { Reference, GraphEntity } from '../types.js'; import { spreadRemainingProperties } from '../types.js'; interface VideoObjectCoreFields { @@ -36,8 +36,8 @@ const HANDLED_KEYS = new Set([ /** * Build a schema.org VideoObject piece. */ -export function buildVideoObject(input: VideoObjectInput, ids: IdFactory): Record { - const piece: Record = { +export function buildVideoObject(input: VideoObjectInput, ids: IdFactory): GraphEntity { + const piece: GraphEntity = { '@type': 'VideoObject', '@id': ids.videoObject(input.url), name: input.name, diff --git a/packages/seo-graph-core/src/pieces/webpage.ts b/packages/seo-graph-core/src/pieces/webpage.ts index a9ce6cb..07178a4 100644 --- a/packages/seo-graph-core/src/pieces/webpage.ts +++ b/packages/seo-graph-core/src/pieces/webpage.ts @@ -1,7 +1,7 @@ import type { WebPageLeaf } from 'schema-dts'; import type { IdFactory } from '../ids.js'; -import type { Reference, CreativeWorkFields } from '../types.js'; +import type { Reference, CreativeWorkFields, GraphEntity } from '../types.js'; import { applyCreativeWorkFields, spreadRemainingProperties, @@ -52,12 +52,12 @@ export function buildWebPage( input: WebPageInput, ids: IdFactory, type: WebPageType = 'WebPage', -): Record { +): GraphEntity { const potentialAction: ReadonlyArray> = input.potentialAction ?? [ { '@type': 'ReadAction', target: [input.url] }, ]; - const piece: Record = { + const piece: GraphEntity = { '@type': type, '@id': ids.webPage(input.url), url: input.url, diff --git a/packages/seo-graph-core/src/pieces/website.ts b/packages/seo-graph-core/src/pieces/website.ts index 2691cbc..f9f9123 100644 --- a/packages/seo-graph-core/src/pieces/website.ts +++ b/packages/seo-graph-core/src/pieces/website.ts @@ -1,7 +1,7 @@ import type { WebSiteLeaf } from 'schema-dts'; import type { IdFactory } from '../ids.js'; -import type { Reference, CreativeWorkFields } from '../types.js'; +import type { Reference, CreativeWorkFields, GraphEntity } from '../types.js'; import { applyCreativeWorkFields, spreadRemainingProperties, @@ -33,8 +33,8 @@ const HANDLED_KEYS = new Set([ * Build a schema.org WebSite piece. This is the site-wide singleton; * every page's WebPage should reference it via `isPartOf`. */ -export function buildWebSite(input: WebSiteInput, ids: IdFactory): Record { - const piece: Record = { +export function buildWebSite(input: WebSiteInput, ids: IdFactory): GraphEntity { + const piece: GraphEntity = { '@type': 'WebSite', '@id': ids.website, url: input.url, diff --git a/packages/seo-graph-core/test/pieces.test.ts b/packages/seo-graph-core/test/pieces.test.ts index cd2727c..f2354c9 100644 --- a/packages/seo-graph-core/test/pieces.test.ts +++ b/packages/seo-graph-core/test/pieces.test.ts @@ -117,6 +117,36 @@ describe('assembleGraph', () => { expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); }); + + it('composes builder outputs directly, with no cast', () => { + // Type-level regression guard: builders return `GraphEntity`, so + // their results flow straight into `assembleGraph` without an `as` cast. When the builders returned + // `Record` this file failed `tsc` (the missing + // `@type` made the array incompatible with the constraint). + const graph = assembleGraph([ + buildWebSite({ url: siteUrl, name: 'Example', publisher: { '@id': ids.person } }, ids), + buildWebPage({ url: postUrl, name: 'My Post', isPartOf: { '@id': ids.website } }, ids), + buildArticle( + { + url: postUrl, + isPartOf: { '@id': ids.webPage(postUrl) }, + author: { '@id': ids.person }, + publisher: { '@id': ids.person }, + headline: 'My Post', + description: 'desc', + datePublished: new Date('2026-01-01T00:00:00.000Z'), + }, + ids, + ), + ]); + expect(graph['@graph']).toHaveLength(3); + expect(graph['@graph'].map((entity) => entity['@type'])).toEqual([ + 'WebSite', + 'WebPage', + 'Article', + ]); + }); }); describe('buildWebSite', () => { @@ -398,6 +428,24 @@ describe('buildArticle', () => { expect(article.wordCount).toBe(100); expect(article.articleBody).toBe('Hello world'); }); + + it('accepts an array of isPartOf references and emits them verbatim', () => { + const blogId = `${siteUrl}#blog`; + const isPartOf = [{ '@id': ids.webPage(postUrl) }, { '@id': blogId }]; + const article = buildArticle( + { + url: postUrl, + isPartOf, + author: { '@id': ids.person }, + publisher: { '@id': ids.person }, + headline: 'Hello', + description: 'World', + datePublished: new Date('2026-01-01T00:00:00.000Z'), + }, + ids, + ); + expect(article.isPartOf).toEqual(isPartOf); + }); }); describe('buildBreadcrumbList', () => {