@jdevalk/seo-graph-core
v0.6.2
Published
Pure schema.org JSON-LD graph builders. Runtime-agnostic core for agent-ready SEO.
Maintainers
Readme
@jdevalk/seo-graph-core
Pure schema.org JSON-LD graph builders. Runtime-agnostic core for agent-ready SEO.
What this is
A small, dependency-light library that builds a valid schema.org @graph
from a set of typed inputs. It does one thing: turn structured page data
into byte-correct JSON-LD that search engines and agents can consume.
It does not know anything about Astro, Next.js, EmDash, WordPress, or
any other runtime. Use @jdevalk/astro-seo-graph
for the Astro integration, or consume this directly from your own CMS or
framework.
For detailed usage — including all builder signatures, site-type recipes, and schema.org best practices — see AGENTS.md.
Install
npm install @jdevalk/seo-graph-coreWhat you get
Graph assembly
| API | Purpose |
| ---------------------------------- | ----------------------------------------------------------------------------------------- |
| makeIds({ siteUrl, personUrl? }) | IdFactory for stable @id references across site-wide and per-page entities. |
| assembleGraph(pieces) | Wraps pieces in a { @context, @graph } envelope with first-wins deduplication by @id. |
| deduplicateByGraphId(entities) | The dedup engine on its own, in case you need custom assembly. |
Pass { warnOnDanglingReferences: true } to assembleGraph to emit a
console warning when any { '@id': '...' } reference doesn't resolve to
an entity in the graph — a cheap sanity check to catch typos and
missing pieces during development.
IndexNow
| API | Purpose |
| --------------------------- | ------------------------------------------------------------------------------ |
| submitToIndexNow | POST URLs to the IndexNow aggregator. Filters by host, dedupes, chunks at 10k. |
| generateIndexNowKey | Generate a random hex key (Web Crypto). |
| validateIndexNowKey | Verify a key is 8–128 chars from [A-Za-z0-9-] (IndexNow spec allow-list). |
| getIndexNowKeyFileContent | Body to serve at /<key>.txt for host verification. |
Piece builders
All builders take an input object and the IdFactory, and return a plain
object with @type and @id. Builders for CreativeWork subtypes (WebSite,
WebPage, Article) share a common set of optional fields via
CreativeWorkFields: description, inLanguage, datePublished,
dateModified, about, copyrightHolder, copyrightYear,
copyrightNotice, license, and isAccessibleForFree.
| Builder | Schema.org type | Subtype parameter |
| ---------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| buildWebSite | WebSite | — |
| buildWebPage | WebPage | 'WebPage' | 'ProfilePage' | 'CollectionPage' |
| buildArticle | Article | 'Article' | 'BlogPosting' | 'NewsArticle' | 'TechArticle' | 'ScholarlyArticle' | 'Report' |
| buildBreadcrumbList | BreadcrumbList | — |
| buildImageObject | ImageObject | — |
| buildVideoObject | VideoObject | — |
| buildSiteNavigationElement | SiteNavigationElement | — |
| buildPiece | Any | schema-dts generic for autocomplete (e.g. buildPiece<Product>, buildPiece<Person>) |
All schema.org properties are accepted at the top level with full autocomplete
from schema-dts. Dedicated builders handle ID generation, date conversion,
and non-trivial transforms. Use buildPiece<Type> for everything else
(Person, Organization, Blog, Product, Recipe, Event, etc.).
Usage
import {
makeIds,
assembleGraph,
buildWebSite,
buildArticle,
buildWebPage,
buildBreadcrumbList,
} from '@jdevalk/seo-graph-core';
const ids = makeIds({ siteUrl: 'https://example.com' });
const url = 'https://example.com/my-post/';
const graph = assembleGraph([
buildWebSite(
{
url: 'https://example.com/',
name: 'Example',
publisher: { '@id': ids.person },
inLanguage: 'en-US',
},
ids,
),
buildWebPage(
{
url,
name: 'My Post',
isPartOf: { '@id': ids.website },
breadcrumb: { '@id': ids.breadcrumb(url) },
datePublished: new Date('2026-04-07'),
},
ids,
),
buildArticle(
{
url,
isPartOf: { '@id': ids.webPage(url) },
author: { '@id': ids.person },
publisher: { '@id': ids.person },
headline: 'My Post',
description: 'A post about something interesting.',
datePublished: new Date('2026-04-07'),
},
ids,
'BlogPosting',
),
buildBreadcrumbList(
{
url,
items: [
{ name: 'Home', url: 'https://example.com/' },
{ name: 'My Post', url },
],
},
ids,
),
]);
// graph === { '@context': 'https://schema.org', '@graph': [...] }IndexNow
Runtime-agnostic helpers for the IndexNow protocol.
Submit URLs to participating search engines (Bing, Yandex, Seznam, Naver, Yep)
through the neutral aggregator at api.indexnow.org.
import {
generateIndexNowKey,
getIndexNowKeyFileContent,
submitToIndexNow,
validateIndexNowKey,
} from '@jdevalk/seo-graph-core';
const key = generateIndexNowKey(); // 32-char hex, persist this
// Serve this body at https://example.com/<key>.txt
const keyFileBody = getIndexNowKeyFileContent(key);
const results = await submitToIndexNow({
host: 'example.com',
key,
urls: ['https://example.com/blog/new-post/'],
});URLs not on host and duplicates are filtered automatically. Bulk submissions
are chunked at 10,000 URLs per request. submitToIndexNow never throws on
network errors — it returns one result per chunk with ok, status, and
message. Pass endpoint to override the default aggregator, keyLocation
to point at a non-default key-file path, or fetch for testing.
Deploy the key file before submitting. IndexNow verifies host ownership by fetching
https://<host>/<key>.txt(orkeyLocation) on every call. Submissions sent before the file is publicly reachable are rejected with HTTP 403 and the key is marked invalid — rotate withgenerateIndexNowKey()if that happens.
Why
Read more about why this project exists.
License
MIT © Joost de Valk
