A zero-dependency, manifest-driven capability broker for web components. It lets independently-authored component libraries share a store, a session, and capabilities — and coordinate navigation — without importing each other and without page glue code.
Each library ships (or is described by) a small JSON manifest declaring what it provides and
consumes. One <script> tag names everything the page draws from those libraries — components
(data-components), shared objects (data-objects), and attributes (data-attributes) — and the
broker pairs each opted-in consumer with a provider in another library. The result is plain custom
elements that interoperate.
The opt-in invariant: nothing ci activates that the script tag doesn’t name. Load as many manifests as you like; until the page names a capability, it stays dormant — so the tag is the complete inventory of what the page uses and what cross-wires.
Library author? See Making your library shareable — the manifest entries and one-function recipes for sharing in every direction.
<script src="component-interop.js"
data-manifest="my-lib.manifest.json other-lib.manifest.json"
data-components="my-widget"
data-objects="store"></script>
It is a classic script (no build step needed), exposes window.ComponentInterop, and has no
runtime dependencies. Load it as a plain blocking <script> in <head> — not async,
defer, or type="module". It reads the manifests and brokers the libraries. (If a manifest also
supplies an import map, ci injects it synchronously before the parser moves on — see
Using the manifest for import maps — because a map
added after any module load has started is rejected.)
A manifest’s offerings — what you read to use a library — are components (elements
to place), attributes (a data-* you use), and objects (a value shared
library→library):
import()s the modules named in data-components (or "*" for all). Each
resolves against its manifest’s URL if relative/absolute, or via an import map if a bare specifier.data-* loads its module(s) when the page
both names it in data-attributes and uses it in the DOM. Named-but-unused loads
nothing; used-but-unnamed loads nothing.consumes/accepts with another library’s
provides of the same key (the “adopt the other library’s value” rule), but only for keys the
page opts into with data-objects="key …". No opt-in → no cross-wiring, even when both
manifests declare the channel.ComponentInterop.services (register / whenReady / get / has)
so libraries publish and discover shared objects without importing each other.Offerings (read these to use the library) — components, attributes, objects.
(A manifest can ALSO carry an import map — shared-modules/stages — but that’s
optional; see Using the manifest for import maps.
A group of modules that loads as one unit is just a barrel module — a JS file
that imports its constituents — named like any other module.)
{
"@context": "https://jeff-zucker.github.io/component-interop/context.jsonld",
"@id": "", // ↑ JSON-LD header: ci ignores these three;
"@type": "Manifest", // they make the manifest a valid RDF document
"name": "my-lib", // library identity (required for sharing)
"components": {
"my-widget": "./my-widget.js", // placeable element → module (URL or bare specifier)
"my-card": { // …or an object: module + display metadata
"module": "./my-card.js", // (module optional when `stages` carry the URLs)
"label": "Card", // label for a tab / button / palette card
"icon": "🃏", // emoji or icon URL
"title": "Browse a card deck", // hover text
"description": "One-line description.",
"params": [{ "name": "source", "value": "./cards.ttl" }], // default attributes
"shape": "./shapes/card.shacl", // SHACL shape its data conforms to
"data": ["./data/cards.ttl"], // data document(s) it reads/writes
"help": "./help/my-card.html" // USER online help (not dev docs)
}
},
"attributes": { // a data-* → its module(s) (page opts in via data-attributes)
"data-login": { "module": "./my-login.js" },
"data-edit-shape data-subject": { "module": "./editing.js" } // space-separated keys share modules
},
"objects": {
"provides": { // offer a value (from a service or an event)
"store": { "service": "store", "sendValue": "graph" },
"navigation": { "respondTo": "my:navigate", "sendValue": "detail.url" }
},
"consumes": { "store": { "call": "adoptStore" } }, // adopt by calling a registered handler
"accepts": { "navigation": { "onElement": "other-viewer", "applyValueTo": "src", "transform": "stripHash" } } // adopt by setting an attr
}
}
{ service, sendValue } (a registered service) or
{ respondTo, sendValue } (a DOM CustomEvent; respondTo may be a list). sendValue
dot-walks into the value. An optional priority (default 0) ranks it when several
libraries provide the same key.call). The library registers it:
ComponentInterop.registerConsumer('adoptStore', (graph) => myStore.use(graph));
The broker invokes the registered function — never an arbitrary string — so it stays ignorant of
any library’s actual API. An optional from: "<lib>" names a preferred provider.
emits sets it; the broker
applies it to every other library’s accepts — for keys the page opted into via data-objects.The dividing rule — exposing a value is data; adopting one is behavior. A
providesentry is just the broker reading a value and handing it out, so it lives entirely in the manifest (no code at all if you already fire the event). Aconsumesentry needs the one registered function, because integrating a foreign value into your own runtime is your logic. So a library makes a resource offerable with a manifest add, and adoptable with one function.
The broker is N-library by design — list more manifests; it pairs every opted-in
consumes (the keys named in data-objects) with a provider in any other library, and the
resource channel + shared services registry are page-wide, so all of them share one
store/session/current-resource with no per-pair glue:
<script src="component-interop.js"
data-manifest="lib-a.manifest.json lib-b.manifest.json lib-c.manifest.json"
data-components="lib-a lib-b lib-c"
data-objects="rdf:lib-b navigation"></script> <!-- rdf hosted by lib-b; navigation: no preference -->
Conventions that make N libraries coherent:
consumes.rdf pairs with
another library’s provides.rdf. Across ecosystems either agree on names or ship a tiny
descriptor manifest mapping a library’s terms to the shared ones (see examples/solpos/,
which describes PodOS in ~14 lines).data-prefer, a JSON map capability →
library, or the key:provider inline host in data-objects like store:pod-os — an
explicit data-prefer wins if both name a key) → the consumer’s from → highest provider
priority → earliest in manifest order.
See examples/multi-provider/.registerConsumer handler names are
page-global — prefix them (libA.adoptStore) so two libraries don’t collide.data-manifest may point at a cross-origin manifest (it loads when
that server sends CORS headers), and the modules a manifest names can be cross-origin too (a CDN).
A small local descriptor manifest per library that maps its terms/modules is still handy for
adapting a foreign library — the examples/solpos/ pattern — but it’s no longer required just to
cross an origin.data-* attributes (data-base, data-stage, …)data-components — components to import() (or * for every component; a token may also name any importmap entry, e.g. a barrel module like rdf-bundle)data-objects — object-capability keys to opt into (wires consumes/accepts; also eager-loads a key’s module). A token may name its host inline — key:provider, e.g. store:pod-os — which also sets the provider preferencedata-attributes — manifest data-* keys to opt into (a key loads only when named here and present in the DOM)data-stage — local |
cdn |
auto — picks stages.<stage> for the optional import map (see below) |
data-manifest — manifest URLs to merge (after the default; resolved against the page; cross-origin
allowed when the server sends CORS)data-manifest-default="off" — skip the default sibling <basename>.manifest.jsondata-importmap-extra — inline importmap JSON (manifest entries win on conflict)data-base — base URL for resolving data-manifest pathsdata-prefer — JSON map key → preferred provider library, for multi-library pages (wins over a key:provider inline host in data-objects)(There is no data-extend-with — an attribute loads when it is named in data-attributes and used on the page.)
Everything above works whether or not ci builds an import map: if a manifest references its modules by
relative/absolute URL, ci import()s them directly. A manifest can ALSO carry an import map, so
libraries reference their deps by bare specifier instead of hard-coding URLs:
{
"name": "my-lib",
"shared-modules": { "rdflib": "./vendor/rdflib.js" }, // externalized deps → URL
"components": { "my-widget": "./my-widget.js" }, // component URLs also feed the map
"stages": { // optional per-env URL sets, chosen by data-stage
"local": { "shared-modules": { "rdflib": "./vendor/rdflib.js" } },
"cdn": { "shared-modules": { "rdflib": "https://esm.sh/rdflib" } }
}
}
ci merges every manifest’s entries first-wins into one <script type="importmap"> and injects it
synchronously, before any module loads (a map added after a module load is rejected — Firefox
strict, Chromium lenient). data-stage (local|cdn|auto — auto = local on localhost/file:,
else cdn) picks the stage; data-importmap-extra adds inline entries. If the page already owns an
<script type="importmap">, ci yields to it and uses that instead.
Why you’d want it: bare specifiers stay location-flexible — swap dev↔CDN by editing only the map/stage, not every manifest — and the union of every library’s externalized deps is collected and deduped to one instance automatically (one rdflib), instead of each app hand-authoring and reconciling a map.
When you don’t need it: reference modules by relative/absolute URL in the manifest and ci imports
them directly — no import map at all. (Bare runtime deps a library imports internally — its own
import 'rdflib' — still need an import map, ci’s or a page-owned one, regardless; that part isn’t
ci-specific.)
window.ComponentInterop)ready (Promise) · load(components) · manifest (.components / .attributes / .meta —
per-tag display metadata: label, icon, title, description, params, shape, data, help) ·
loaded · version ·
registerCapability(name, {modules, attributes}) · registerConsumer(name, fn) ·
services (register / get / has / names / whenReady) · has(name) · capabilities ·
on(name, fn) · emit(name, detail). Events: interop:ready, interop:capability (per
capability), interop:wired (per provide→consume binding).
A manifest names modules ci import()s, so point data-manifest only at manifests you trust — the
page author chooses them, and a cross-origin one loads only if that server sends CORS headers.
Consumer handlers are invoked from a library-provided registry, never eval‘d from a manifest
string, so a manifest can name a handler but never supply code.
examples/ is a live, runnable demo — a PodOS app gaining Solid Web Components capabilities, wired
entirely by the broker, no glue. Open examples/index.html (a tabbed shell): SPARQL on a plain
element, a shared store, shared navigation, auto-generated forms, and auth — each a swc capability
adopted by a PodOS page. It loads sol-components and
PodOS from the CDN, so it runs from this repo alone.