@effected/schema-org
v0.2.0
Published
schema.org vocabulary as Effect Schema classes, with a script-safe JSON-LD serializer
Downloads
561
Readme
@effected/schema-org
schema.org as Effect Schema classes: build a JSON-LD graph, serialize it safely into a <script> element, and check it against schema.org's own vocabulary offline. effect is the only peer dependency and there are no runtime dependencies at all — the vocabulary is vendored as generated TypeScript, so nothing here reaches the network.
Pre-release. This package is part of the
@effected/*kit, in pre-1.0.0development against a single pinned Effect v4 prerelease. Packages graduate to1.0.0once Effect4.0.0ships. To hold your owneffectversions at exactly the ones the kit is built and tested against, install@effected/pnpm-plugin-effect.Stability: unstable. This package's API surface is not yet considered complete and may change across
0.xreleases. Pin an exact version — even a package marked stable before1.0.0can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.
Why @effected/schema-org
Structured data is usually assembled as an object literal and dropped into a template with JSON.stringify, which gets two things wrong at once. The first is a security bug: JSON.stringify does not escape <, so one description containing the literal </script> closes the JSON-LD block early and injects whatever follows into the page. The second is quieter — schema.org has a vocabulary, your object literal does not, and a plausible-looking property the vocabulary does not define on that type is simply ignored by every consumer, with no error anywhere.
This package fixes both at their source. toScriptBody() is the only text serializer and it is the escaped one, so the injection case cannot be reached by choosing the wrong function. The vocabulary ships vendored, so a build step can reject a misplaced property offline, in CI, with no network call and no crawler round trip.
Install
npm install @effected/schema-org effectpnpm add @effected/schema-org effectRequires Node.js >=24.11.0.
All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.
Quick start
Assemble nodes into a graph, then serialize it:
import { JsonLdDocument, NodeRef, SoftwareSourceCode, TechArticle } from "@effected/schema-org";
import { Result } from "effect";
const built = JsonLdDocument.buildResult([
SoftwareSourceCode.make({ "@id": "https://example.com/pkg#source", name: "example", version: "1.2.3" }),
TechArticle.make({
"@id": "https://example.com/pkg/docs#intro",
headline: "Getting started </script>",
isPartOf: [NodeRef.to("https://example.com/pkg#source")],
}),
]);
console.log(Result.getOrThrow(built).toScriptBody());
// {"@context":"https://schema.org","@graph":[{"@id":"https://example.com/pkg#source","@type":"SoftwareSourceCode","name":"example","version":"1.2.3"},{"@id":"https://example.com/pkg/docs#intro","@type":"TechArticle","isPartOf":[{"@id":"https://example.com/pkg#source"}],"headline":"Getting started </script>"}]}Then embed the body verbatim:
<script type="application/ld+json">BODY</script>buildResult is the entry point, not make: Schema.Class reserves make for its raw structural constructor, which runs none of the identity checks. JsonLdDocument.build is the Effect twin of buildResult, derived from it — the synchronous form is the primitive, so a static-site build never has to construct a runtime.
Three things fail a build, all of them caller error rather than something to report to a user: a malformed @id (InvalidNodeIdError), two nodes claiming one @id (DuplicateNodeIdError), and a catch-all key colliding with a typed field (ConflictingTermError). A dangling reference is legal — pointing at a node described on another page is correct JSON-LD — so read danglingReferences and decide for yourself whether your graph is meant to be closed.
The serializer is the point
Every string in your graph comes from prose someone wrote. A description containing the literal </script> closes the JSON-LD block early and injects markup into your page, and JSON.stringify does not escape it.
toScriptBody() escapes <, > and & after stringify. All three are valid JSON string escapes, so a parser sees the original characters; with no < in the body, neither </script nor <!-- can appear, and the element cannot be closed from inside. The & escape additionally keeps the output well-formed when the document is served as XHTML. The escaping is idempotent, so a second pass over the output is safe rather than double-escaped.
There is deliberately no unescaped text serializer. The escaped output is not a restricted form — it is semantically identical for anyone who parses it, which is everyone — so a second entry point could only ever be chosen wrongly.
toJsonLd() returns the wire form as a plain value, for a framework that serializes JSON-LD itself. If you are producing text, do not use it — JSON.stringify(doc.toJsonLd()) reintroduces exactly the bug this package exists to prevent.
Validate offline
import { Conformance, Vocabulary } from "@effected/schema-org/validate";
console.log(Vocabulary.version);
// 30.0
for (const issue of Conformance.check(doc)) {
console.error(issue._tag, issue.message);
}
// PropertyNotOnType https://example.com/pkg#source: schema.org does not define "softwareVersion" on SoftwareSourceCodeConformance.check is total — it never fails and never throws, because reporting is not a failure mode. Conformance.validateResult is the gate built on top of it, returning the graph back or a NonConformantGraphError carrying every issue, and Conformance.validate is its Effect twin. By default only the structural kinds close the gate; ConformanceOptions widens it to deprecations and dangling references.
The vocabulary is vendored, so the gate runs offline and in CI with no network call. It catches the failure that actually happens — not malformed JSON, but a plausible property schema.org does not define on that type. softwareVersion on a SoftwareSourceCode is the canonical example: it reads correct, serializes fine, and is silently ignored downstream. (The property is version.)
Legality is resolved through the full rdfs:subClassOf ancestor closure, so inherited properties are accepted — license is declared only on CreativeWork, and it is legal on SoftwareSourceCode because of that chain.
Import it from the ./validate subpath. The vocabulary table is the whole of what a graph-only consumer avoids: importing @effected/schema-org loads the node classes and the serializer and nothing else. ./validate re-exports the node classes type-only, for annotating a signature — reaching for a value through it, such as NodeRef.to, fails with "only refers to a type". Import values from the root.
Conformance is not Google rich-results eligibility. Google requires properties schema.org does not, and changes its policy on its own schedule. This tells you schema.org defines the term; it does not promise you a rich result.
What this package does not do
- Mint
@ids. They are IRIs in your namespace; the package cannot know your base URL and never derives one. - Map your domain onto nodes. That mapping is yours.
- Decode JSON-LD back into typed nodes. The encode direction is the supported one in this release, and a test pins the asymmetry so it cannot start half-working by accident.
- Model all of schema.org. Six node types, plus an open
additionalcatch-all for any term — which the validator then checks.
Features
SoftwareSourceCode,TechArticle,APIReference,Person,Organization,CreativeWork— the six node classes. Every node requires@idand nothing else; every other field is optional and may be passed asundefined, which is dropped at serialization.ThingFields,CreativeWorkFields,TechArticleFields— the shared field records the node classes are assembled from, exported for a consumer building a node type this package does not model.NodeRef— a reference to another node by@id, withto(unchecked, validated at build time), the synctoCheckedResultand itsEffecttwintoChecked, and theisValidIdpredicate. A node-valued property holds aNodeRef, never an embedded node.JsonLdDocument— the graph:buildResult/buildto assemble and check identity,nodeIdsanddanglingReferencesto inspect,toScriptBody()to serialize for a page andtoJsonLd()for a framework that serializes JSON-LD itself.Vocabulary— a read API over the vendored schema.org vocabulary:version,hasType,hasProperty,ancestorsOf,propertiesOf,isPropertyOnandsupersededBy, usable on its own for tooling that never builds a graph.Conformance— offline conformance checking, reportingUnknownTerm,PropertyNotOnType,DeprecatedType,DeprecatedPropertyandDanglingReferenceissues, each with a one-linemessage.- A property that repeats is always an array, even at length one — including
license, becauseMIT AND Apache-2.0is a real dual-license and collapsing it would be silent data loss.
