Composable builders for Collection+JSON 1.0, with additive runtime extensions.
The package keeps the Collection+JSON vocabulary intact while adding optional metadata useful to richer APIs. Builders are immutable and intentionally composable: they can also be used as incomplete descriptors (for example a link template without an href) and projected later by a higher-level layer such as @lcf.vs/collection-api.
validate(document) validates a final representation before it is exposed as application/vnd.collection+json.
It enforces the Collection+JSON 1.0 constraints that affect serialized output, including:
- Collection+JSON version
1.0; - valid absolute URIs for standard
hrefmembers; - required
nameon serialized data elements; - required
hrefandrelon serialized links and queries; renderlimited toimageorlink;- data values limited to
string, finitenumber,boolean, ornull; - the standard top-level
{ "template": ... }representation used for POST/PUT writes.
Unknown foreign markup is preserved and ignored by the validator, following Collection+JSON's extensibility rule.
import { validate } from '@lcf.vs/collection'
validate(result)Builders themselves remain composable rather than pretending every intermediate descriptor is a complete Collection+JSON document. withValue(), withRender(), and withVersion() nevertheless reject values that can never be legal Collection+JSON values.
validate(document) remains fail-fast and returns the unchanged document when it is valid. Invalid final representations now throw ValidationError, which extends TypeError for compatibility and exposes an immutable issues array:
import {
ValidationError,
validate
} from '@lcf.vs/collection'
try {
validate(received)
} catch (error) {
if (error instanceof ValidationError) {
console.log(error.issues)
}
}Each issue has a stable machine-readable shape and an RFC 6901 JSON Pointer:
{
pointer: '/collection/items/1/data/0/value',
code: 'invalid_value',
message: 'must be a Collection+JSON VALUE (string, finite number, boolean, or null)'
}For forms, tooling, documentation, or editors that need every discoverable problem rather than fail-fast control flow, use inspect(document, options?):
import { inspect } from '@lcf.vs/collection'
const issues = inspect(received)
if (issues.length) {
// render all diagnostics without catching
}inspect() never normalizes or coerces the representation. Validation and transformation intentionally remain separate operations.
Runnable example: npm run example-inspect.
Declared extension validators participate in the same diagnostic mechanism. Their context now contains both pointer/path and an issue({ code, message, pointer? }) helper. Throwing remains supported for simple validators; issue() allows an extension to emit a structured diagnostic without manufacturing a ValidationError itself.
The package adds foreign markup without changing the meaning of any standard Collection+JSON member:
collection.scripts: executable/client metadata built withscript(src,type,integrity);error.details: structured error details built withdetail;link.typeandlink.integrity: additional link metadata;query.linkTemplatesandtemplate.linkTemplates: link-oriented input metadata.
Use .withLinkTemplates() for the last extension:
query.withLinkTemplates([
link
.withPrompt('Related resource')
.withRel('related')
])query.withLinks() and template.withLinks() remain available only for backward compatibility with the historical API. Their serialized links member is not valid extension markup in those locations, and validate() therefore rejects it. New code should always use .withLinkTemplates().
Collection+JSON explicitly allows foreign markup as long as it does not redefine the standard vocabulary. @lcf.vs/collection exposes that capability to consumers instead of limiting extensions to the ones shipped by this package.
Create an immutable descriptor with extension({ name, validate }), then attach a value with .withExtension():
import {
collection,
document,
extension,
item,
validate
} from '@lcf.vs/collection'
const permissions = extension({
name: 'permissions',
validate (value, { path }) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError(`${path} must be an object`)
}
if (typeof value.read !== 'boolean') {
throw new TypeError(`${path}.read must be a boolean`)
}
}
})
const result = document.withCollection(collection
.withHref('https://api.example.com/items')
.withItems([
item
.withHref('https://api.example.com/items/1')
.withExtension(permissions, { read: true })
])
.withVersion('1.0'))
validate(result, {
extensions: [permissions]
})Descriptors are plain immutable values. There is no global registry and therefore no import-order dependency. This is also why the same descriptor can validate JSON received from elsewhere:
const received = JSON.parse(payload)
validate(received, {
extensions: [permissions]
})A custom validator receives (value, context). context contains:
name: the extension member name;path/pointer: its RFC 6901 JSON Pointer location in the validated representation;issue({ code, message, pointer? }): report a structured diagnostic using the same engine as the base vocabulary;target: the Collection+JSON object carrying the extension;document: the complete document being validated.
Extension validators are synchronous. They may return anything; validation succeeds unless they throw. Returning a Promise is rejected so validate() remains deterministic and synchronous.
When no reusable validator is needed, .withExtensions() attaches arbitrary foreign markup directly:
const extended = item.withExtensions({
vendorMetadata: {
source: 'example'
}
})Unknown foreign markup is preserved by validate() and ignored semantically, as required for Collection+JSON clients that do not understand an extension.
Both .withExtension() and .withExtensions() reject names from the Collection+JSON vocabulary. For example, an extension cannot be named href, data, links, template, or version. They also reject a name that would overwrite a builder method such as withHref. validate() rejects a standard member manually inserted at an invalid Collection+JSON location, preventing foreign markup from accidentally redefining the base vocabulary.
Because future Collection+JSON revisions may add vocabulary, extension authors should prefer distinctive or namespaced member names when designing public extensions.
All serializable builders (document, collection, item, property, link, query, template, error, script, and detail) expose these two generic methods.
The extensions shipped by this package are descriptors too and are exported as extensions:
import { extensions } from '@lcf.vs/collection'
extensions.scripts.name // 'scripts'
extensions.details.name // 'details'
extensions.integrity.name // 'integrity'
extensions.type.name // 'type'
extensions.linkTemplates.name // 'linkTemplates'The convenience methods .withScripts(), .withDetails(), .withIntegrity(), .withType(), and .withLinkTemplates() attach their values through the same generic .withExtension() primitive available to third-party packages. Their semantics are always validated by the canonical Collection+JSON validator; the exported descriptors are also useful for composition and introspection.
npm run example-extension is a runnable declared-extension example.
import {
collection,
document,
item,
link,
property,
query,
script,
template,
validate
} from '@lcf.vs/collection'
const result = document.withCollection(collection
.withHref('https://api.example.com/items')
.withItems([
item
.withHref('https://api.example.com/items/1')
.withData([
property.withName('id').withValue(1),
property.withName('name').withValue('product')
])
])
.withQueries([
query
.withHref('https://api.example.com/items/search')
.withRel('search')
.withData([
property.withName('search').withValue('product')
])
.withLinkTemplates([
link.withRel('related')
])
])
.withScripts([
script.withSrc('https://api.example.com/main.js')
])
.withTemplate(template
.withData([
property.withName('name')
]))
.withVersion('1.0'))
validate(result)The complete runnable example also demonstrates error details, links, prompts, script integrity/type, and link templates.
{
"collection": {
"error": {
"code": "INVALID_ITEM",
"message": "The latest operation failed",
"details": [
{
"message": "The name was invalid",
"name": "name"
}
],
"title": "Invalid item"
},
"href": "https://api.example.com/items",
"items": [
{
"href": "https://api.example.com/items/1",
"data": [
{
"name": "id",
"prompt": "Item ID",
"value": 1
},
{
"name": "name",
"prompt": "Item name",
"value": "product"
}
],
"links": [
{
"href": "https://api.example.com/items/1/rss",
"rel": "rss"
}
]
}
],
"links": [
{
"href": "https://api.example.com/items",
"rel": "top"
}
],
"queries": [
{
"data": [
{
"name": "search",
"prompt": "Item name to search",
"value": "product"
}
],
"href": "https://api.example.com/items/search",
"linkTemplates": [
{
"prompt": "Optional related resource",
"rel": "related"
}
],
"prompt": "Search an item",
"rel": "search"
}
],
"scripts": [
{
"integrity": "sha384-example",
"src": "https://api.example.com/main.js",
"type": "module"
}
],
"template": {
"data": [
{
"name": "id",
"prompt": "Item ID"
},
{
"name": "name",
"prompt": "Item name"
}
],
"linkTemplates": [
{
"prompt": "Related resource",
"rel": "related"
}
]
},
"version": "1.0"
}
}Collection+JSON uses a top-level template document for POST/PUT request bodies.
import {
document,
property,
template,
validate
} from '@lcf.vs/collection'
const result = document.withTemplate(template.withData([
property
.withName('name')
.withValue('product')
]))
validate(result){
"template": {
"data": [
{
"name": "name",
"value": "product"
}
]
}
}Every withX() method returns a new object. Existing descriptors can therefore be safely reused as contracts and enriched later:
const name = property
.withName('name')
.withPrompt('Item name')
const templateName = name
const itemName = name.withValue('product')This is especially useful with @lcf.vs/collection-api, where the same field descriptors can drive read projection, write templates, queries, links, scripts, and other runtime affordances.
npm testThe suite covers response and write documents, extensions, scalar values, URI validation, render, versioning, foreign markup, and rejection of the legacy query/template links extension in final representations.