Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/article-ispartof-array.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions .changeset/builders-return-graphentity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@jdevalk/seo-graph-core': minor
---

**Piece builders now return `GraphEntity` instead of `Record<string, unknown>`.**

`assembleGraph<T extends GraphEntity>(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<string, unknown>`, 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.
15 changes: 10 additions & 5 deletions packages/seo-graph-core/src/pieces/article.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -67,8 +72,8 @@ export function buildArticle(
input: ArticleInput,
ids: IdFactory,
type: ArticleType = 'Article',
): Record<string, unknown> {
const piece: Record<string, unknown> = {
): GraphEntity {
const piece: GraphEntity = {
'@type': type,
'@id': ids.article(input.url),
isPartOf: input.isPartOf,
Expand Down
8 changes: 3 additions & 5 deletions packages/seo-graph-core/src/pieces/breadcrumb.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -27,10 +28,7 @@ const HANDLED_KEYS = new Set<string>(['url', 'items']);
/**
* Build a schema.org BreadcrumbList piece.
*/
export function buildBreadcrumbList(
input: BreadcrumbListInput,
ids: IdFactory,
): Record<string, unknown> {
export function buildBreadcrumbList(input: BreadcrumbListInput, ids: IdFactory): GraphEntity {
const lastIndex = input.items.length - 1;
const itemListElement = input.items.map((item, index) => ({
'@type': 'ListItem',
Expand All @@ -43,7 +41,7 @@ export function buildBreadcrumbList(
: item.url,
}));

const piece: Record<string, unknown> = {
const piece: GraphEntity = {
'@type': 'BreadcrumbList',
'@id': ids.breadcrumb(input.url),
itemListElement,
Expand Down
12 changes: 8 additions & 4 deletions packages/seo-graph-core/src/pieces/custom.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -26,13 +28,15 @@ export function buildPiece<T extends Thing, TType extends string = string>(
'@type': TType;
'@id'?: string;
},
): Record<string, unknown>;
): GraphEntity;
export function buildPiece(
raw: Record<string, unknown> & {
'@type': string | readonly string[];
'@id'?: string;
},
): Record<string, unknown>;
export function buildPiece(raw: Record<string, unknown>): Record<string, unknown> {
return raw;
): GraphEntity;
export function buildPiece(raw: Record<string, unknown>): GraphEntity {
// The public overloads both require `@type` on `raw`, so the result
// always satisfies GraphEntity; the implementation signature is wider.
return raw as GraphEntity;
}
5 changes: 3 additions & 2 deletions packages/seo-graph-core/src/pieces/image.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,14 +33,14 @@ const HANDLED_KEYS = new Set<string>([
* primary image (id = `${pageUrl}#primaryimage`), or `id` for a site-
* wide image like a personal logo.
*/
export function buildImageObject(input: ImageObjectInput, ids: IdFactory): Record<string, unknown> {
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<string, unknown> = {
const piece: GraphEntity = {
'@type': 'ImageObject',
'@id': resolvedId,
url: input.url,
Expand Down
6 changes: 3 additions & 3 deletions packages/seo-graph-core/src/pieces/navigation.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -27,14 +27,14 @@ const HANDLED_KEYS = new Set<string>(['name', 'isPartOf', 'items']);
export function buildSiteNavigationElement(
input: SiteNavigationInput,
ids: IdFactory,
): Record<string, unknown> {
): GraphEntity {
const hasPart = input.items.map((item) => ({
'@type': 'SiteNavigationElement',
name: item.name,
url: item.url,
}));

const piece: Record<string, unknown> = {
const piece: GraphEntity = {
'@type': 'SiteNavigationElement',
'@id': ids.navigation,
name: input.name,
Expand Down
6 changes: 3 additions & 3 deletions packages/seo-graph-core/src/pieces/video.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -36,8 +36,8 @@ const HANDLED_KEYS = new Set<string>([
/**
* Build a schema.org VideoObject piece.
*/
export function buildVideoObject(input: VideoObjectInput, ids: IdFactory): Record<string, unknown> {
const piece: Record<string, unknown> = {
export function buildVideoObject(input: VideoObjectInput, ids: IdFactory): GraphEntity {
const piece: GraphEntity = {
'@type': 'VideoObject',
'@id': ids.videoObject(input.url),
name: input.name,
Expand Down
6 changes: 3 additions & 3 deletions packages/seo-graph-core/src/pieces/webpage.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -52,12 +52,12 @@ export function buildWebPage(
input: WebPageInput,
ids: IdFactory,
type: WebPageType = 'WebPage',
): Record<string, unknown> {
): GraphEntity {
const potentialAction: ReadonlyArray<Record<string, unknown>> = input.potentialAction ?? [
{ '@type': 'ReadAction', target: [input.url] },
];

const piece: Record<string, unknown> = {
const piece: GraphEntity = {
'@type': type,
'@id': ids.webPage(input.url),
url: input.url,
Expand Down
6 changes: 3 additions & 3 deletions packages/seo-graph-core/src/pieces/website.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -33,8 +33,8 @@ const HANDLED_KEYS = new Set<string>([
* 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<string, unknown> {
const piece: Record<string, unknown> = {
export function buildWebSite(input: WebSiteInput, ids: IdFactory): GraphEntity {
const piece: GraphEntity = {
'@type': 'WebSite',
'@id': ids.website,
url: input.url,
Expand Down
48 changes: 48 additions & 0 deletions packages/seo-graph-core/test/pieces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends
// GraphEntity>` without an `as` cast. When the builders returned
// `Record<string, unknown>` 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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down