@nexuscontent/core
v0.2.10
Published
An open source, framework neutral content access layer for JavaScript and TypeScript applications.
Maintainers
Readme
NexusContent
An open source, framework neutral content access layer for JavaScript and TypeScript applications.
NexusContent provides a consistent API for retrieving normalized content from external sources — Git managed content, WordPress, Strapi, and future providers. It sits between content sources and applications so your frontend never depends directly on a CMS.
Consumers own the application. Content providers own the content. Deployment infrastructure owns delivery.
Why NexusContent
Developers want Git, static builds, and predictable deployments. Content editors want forms, media management, and no code access. NexusContent keeps these concerns separate — your application consumes normalized content without depending on which CMS, framework, or hosting platform is in use.
Content Sources → NexusContent → Consumer Build → DeploymentThe same application should be able to switch from WordPress to Git content without a frontend rewrite.
Install
npm install @nexuscontent/coreQuick Start
import { NexusContent } from "@nexuscontent/core";
const nexus = new NexusContent(config);
const page = await nexus.getPage("about");
const projects = await nexus.getCollection("projects");
const project = await nexus.getItem("projects", "project-one");Works identically in Astro, Next.js, plain Node scripts, or any JavaScript runtime.
Providers
| Provider | Status | Source | |----------|--------|--------| | Git | Implemented | JSON content directories | | WordPress | Implemented | WordPress REST API | | Strapi | Planned | Strapi REST API |
Git Provider
Content lives in a separate repository. Point NexusContent at it:
import { defineNexusConfig, NexusContent } from "@nexuscontent/core";
const nexus = new NexusContent(
defineNexusConfig({
providers: {
content: {
type: "git",
options: { contentPath: "../client-content" }
}
},
schema: {
models: {
home: {
kind: "singleton",
source: { provider: "content", key: "home" },
fields: {
hero: {
type: "object",
fields: {
heading: { type: "string", required: true },
intro: { type: "string" }
}
}
}
},
about: {
kind: "singleton",
source: { provider: "content", key: "about" }
}
}
}
})
);Content structure:
client-content/
├── pages/
│ ├── home.json
│ └── about.json
├── collections/
│ └── team/
├── navigation/
│ └── main.json
└── settings/
└── site.jsonWordPress Provider
import {
defineNexusConfig,
NexusContent,
WordPressMediaProvider,
WordPressProvider
} from "@nexuscontent/core";
const wordpress = new WordPressProvider({
baseUrl: "https://wordpress.example.com/wp-json/wp/v2"
});
const nexus = new NexusContent(
defineNexusConfig({
providers: {
wordpress: {
type: "wordpress",
options: { baseUrl: "https://wordpress.example.com/wp-json/wp/v2" }
}
},
schema: {
models: {
home: {
kind: "singleton",
source: { provider: "wordpress", key: "home" }
},
posts: {
kind: "collection",
source: { provider: "wordpress", key: "posts" }
}
}
}
})
);
nexus.register("wordpress", wordpress);
const home = await nexus.getPage("home");
const posts = await nexus.getCollection("posts");Model Schema
The schema.models contract declares where each logical model's content comes
from and what its data should look like. Model kind selects the retrieval
operation: singleton models route through getPage (Git
pages/<key>.json), collection through getCollection, navigation
through getNavigation, and settings through getSettings.
Field types are string, number, boolean, datetime, object,
reference, media, richText, component, and blocks. Fields support
required, list, options (enum strings), nested object.fields,
reference.collection references, and media overrides. component fields
reference declared schema.components; blocks fields validate a
discriminated _type list against allowedComponents. Data is validated at
retrieval time; a mismatch throws a SchemaError. Undeclared data fields pass
through.
defineNexusConfig() preserves literal model names and field declarations, so
retrieval methods accept only compatible model kinds and infer each result's
data shape without explicit generic parameters.
fields: {
title: { type: "string", required: true },
status: { type: "string", options: ["draft", "published"] },
tags: { type: "string", list: true },
cover: { type: "media" },
author: { type: "reference", collection: "people" }
}Page sections
Pages returned by getPage() carry the provider's ordered sections in two
forms:
page.sections— a named map keyed by section type, for declarative template composition:<Hero {...page.sections.hero} />. Repeated section types are suffixed in order (hero,hero_2,hero_3).page.sectionsList— the authoritative ordered{ type, data }array, for renderers that iterate sections (<PostSections sections={page.sectionsList} />).
Both arrive identically whether the content comes from Git or a CMS provider.
Git pages author the map directly as sections:{...} in pages/<key>.json
(JSON key order is the section order); WordPress flexible/Gutenberg content is
normalized to the same ordered list and projected to the map. A page model
therefore never needs to declare the sections it uses — leave its fields
off, or declare additional non-section fields:
// content/pages/about.json
{
"key": "about",
"sections": {
"hero": { "heading": "We make content replaceable" },
"rich_text": { "body": "<p>Origin story.</p>" }
}
}Media
Media references stay neutral. Providers normalize source media into
MediaAsset where src is the URL source (migrated from the legacy url
field in 0.2.2):
media: {
default: "remote",
providers: {
local: { type: "local", options: { root: "../client-content/media", publicPath: "/media" } },
remote: { type: "remote" }
}
}local maps root-relative paths to publicPath web URLs with traversal
protection. remote validates absolute http(s) URLs without fetching. A
WordPress media provider resolves ids through the WordPress media endpoint and
is registered manually:
nexus.registerMedia("wordpress", new WordPressMediaProvider({ baseUrl }));
const asset = await nexus.media.resolve({ id: "9" }); // or { src: "..." }For whole content structures (page sections maps, collection item data),
nexus.media.resolveFields(value) recursively resolves every object that
carries a src string into a plain { src, alt }, leaving everything else
untouched — so pages resolve all section media before rendering:
const home = (await nexus.media.resolveFields(page.sections)) as HomeData;When the provider yields no asset, the authored { src, alt } reference is
kept; id-only references are not generically detectable and pass through.
WordPress Options
new WordPressProvider({
baseUrl: "https://wordpress.example.com/wp-json/wp/v2", // required
headers: { Authorization: `Bearer ${token}` }, // optional auth
collections: { books: { endpoint: "books" } }, // custom post types
perPage: 100, // 1-100, default 100
maxPages: 100, // safety limit
timeoutMs: 10000 // request timeout
});With apiStrategy: "auto" or "companion", getSettings() prefers the
companion plugin's public contract-v1 /settings route and falls back to native
wp/v2/settings when that route is unavailable. Companion plugin 0.1.8
provides an ACF Pro/Secure Custom Fields option page for site settings; ACF Free
and plugin-free installs use WordPress core values.
The same plugin release adds page/post SEO authoring. Gutenberg uses a document
sidebar and ACF modes use a field group; both emit optional normalized seo on
page responses. The provider maps companion image url values to MediaAsset.src.
WordPress Components and Synchronisation
Section vocabulary lives in one monorepo canonical file,
integrations/wordpress/nexuscontent/sections.json; the PHP plugin registry and
the generated sections.generated.ts both derive from it (npm run
check:sections enforces freshness in CI).
At build time, validate your declared consumer components against the install:
const wordpress = await new WordPressProvider({
baseUrl: "...",
componentTypeMap: { servicesList: "features" } // rename bridge
});
// Throws wordpress/unknown-component for unresolvable names.
wordpress.validateComponents(schema.components);
// Serializable contract to push to a site (see below):
const contract = wordpress.projectComponentContract(schema); // { components, sectionTypes }During auto/companion API strategy, the provider reconciles its effective
registry against the site's live /schema — install-only sections extend the
registry, and registry-only or conflicting sections surface as structured
diagnostics (thrown in strict companion mode).
Push the project contract to a WordPress site to see expected-vs-installed drift on the plugin Dashboard:
curl -X POST "https://wordpress.example.com/wp-json/nexuscontent/v1/project-contract" \
-H "X-WP-Nonce: <rest-nonce>" \
-H "Content-Type: application/json" \
-d '{"components":["hero","servicesList"],"sectionTypes":["hero","features"]}'The route requires manage_options, stores only sanitized string arrays in
nexuscontent_settings, and never reconfigures editor settings automatically.
It lives outside the content wire contract, so no contractVersion negotiation
applies.
The nexus-contract npm bin (shipped with @nexuscontent/core) drives both
flows from a consumer project using an Application Password, and provides a
config-backed contract workflow so the loop is repeatable and CI-checkable:
# Scaffold the workflow: writes nexus.contract.json (config) and sections.custom.json:
npx @nexuscontent/core nexus-contract init --schema src/schema/schema.ts --write wp-content/mu-plugins/nexuscontent-sections.php
# Re-derive and re-render after schema changes (reads the config; flags override):
npx @nexuscontent/core nexus-contract regenerate
# Classify without writing; fails if the contract references undefined sections:
npx @nexuscontent/core nexus-contract validate
# Scaffold a consumer-owned mu-plugin registering custom sections as ACF layouts:
npx @nexuscontent/core nexus-contract generate --schema src/schema/schema.ts --custom sections.custom.json --write wp-content/mu-plugins/nexuscontent-sections.php
# Post the consumer contract to the admin-only project-contract route:
npx @nexuscontent/core nexus-contract push --schema src/schema/schema.ts --api-root "$WORDPRESS_API_URL" --username admin-user --app-password "xxxx xxxx xxxx xxxx xxxx xxxx"generate derives the { components, sectionTypes } contract from your own
field schema through projectComponentContract, classifies each section type as
installed, custom (emitted), or missing (fails), warns on unused custom
declarations, and emits deterministic PHP registering the custom sections via
nexuscontent_section_definitions so the companion plugin auto-creates their ACF
flexible layouts and optional ACF blocks. Classification uses the site's live
/schema when --api-root is given and otherwise falls back to the bundled
offline vocabulary (scripts/sections.json). regenerate re-runs generate
using the paths recorded by init (explicit flags override), and validate
prints the same drift without writing. Generated mu-plugin code is owned
by the consumer and must not be committed to this repository.
See docs/wordpress-companion.md for the project-facing definition of the companion plugin.
Preview
The companion plugin serves draft/scheduled content for single-use preview. An
editor with edit_posts mints a short-lived, post-scoped token:
curl -X POST "https://wordpress.example.com/wp-json/nexuscontent/v1/preview-token" \
-H "X-WP-Nonce: <rest-nonce>" \
-d '{"postId":42}'
# => { "token": "<64-hex>", "expiresAt": "..." }The public GET /nexuscontent/v1/preview/{token}/{id} route returns the
normalized content envelope for a valid, matching, unexpired token — the token
is the auth, so no session is required. Invalid, expired, or mismatched tokens
return 401 and are revoked on use. The Gutenberg "Open frontend preview"
button (gated by the plugin's preview_frontend_url setting) mints a token and
opens a consumer preview route with ?token=...&id=.... The astro-wordpress
example's preview.astro route is a static, consumer-owned preview that fetches
the tokenized route and renders through the shared section components, emitting
noindex so previews never leak into production.
Webhooks
The companion plugin can notify the consuming frontend of WordPress content
changes. It is opt-in and configured on the plugin's Settings page
(webhook_url, plus an optional shared secret stored in a separate option
that is never echoed or logged). On page/post create, update, trash, or
restore, the plugin POSTs a compact JSON payload carrying only change metadata
(no full content):
{
"event": "updated",
"id": 42,
"type": "page",
"slug": "about",
"status": "publish",
"title": "About",
"modifiedAt": "2026-08-31T12:00:00Z",
"source": "wordpress"
}When a shared secret is set, the request carries an
X-NexusContent-Signature: sha256=<HMAC-SHA256 of the raw JSON body> header so
the consumer can verify the request genuinely came from WordPress. Dispatch is
outbound-only, best-effort, non-blocking, and never triggers rebuilds or other
site mutations — the consumer verifies the signature and decides whether to
act.
Editor Modes
Pages can use one of three editor modes. The companion plugin stores the mode per-page in WordPress, so different pages on the same site can use different modes:
| Mode | Requires | Description |
|------|----------|-------------|
| gutenberg | WordPress core | Native block editor (default) |
| acf_fixed | ACF Free 6.2+ | Fixed Hero, Intro, CTA fields |
| acf_flexible | ACF Pro 6.2+ | Flexible layouts for all 12 sections |
The provider resolves the mode per-page: per-page field → defaultEditorMode → editorMode fallback (defaults to "gutenberg"). With the companion plugin the mode is resolved server-side.
new WordPressProvider({
baseUrl: "...",
editorMode: "gutenberg", // fallback, default "gutenberg"
defaultEditorMode: "acf_fixed" // site-wide intermediate default
});Localisation (Optional)
const nexus = new NexusContent(
defineNexusConfig({
locales: {
default: "en",
supported: ["en", "fr"],
fallback: { fr: "en" }
},
// providers and a schema.models contract as usual
})
);
const page = await nexus.getPage("about", { locale: "fr" });Astro Usage
---
import { NexusContent } from "@nexuscontent/core";
import BaseLayout from "../layouts/BaseLayout.astro";
const nexus = new NexusContent(config);
const page = await nexus.getPage("about");
if (!page) throw new Error("About content was not found.");
---
<BaseLayout title={page.title}>
<h1>{page.title}</h1>
<Fragment set:html={page.data.content} />
</BaseLayout>Content Types
interface PageContent<TData> {
id: string;
key: string;
slug?: string;
title?: string;
seo?: SeoData;
sections?: Record<string, unknown>; // named map keyed by section type
sectionsList?: ContentSection[]; // authoritative ordered sections
data: TData;
meta: { source: string; sourceId?: string; updatedAt?: string; locale?: string };
}
interface CollectionItem<TData> {
id: string;
key: string;
slug?: string;
title?: string;
data: TData;
meta: ContentMeta;
}SEO
import { resolveSeo } from "@nexuscontent/core";
const seo = resolveSeo(
{ title: page.title, excerpt: page.data.excerpt, featuredImage: page.data.featuredImage },
{ siteTitle: "My Site", defaultImage: { src: "https://example.com/social.jpg" } }
);Inline JSON-LD is serialized with the Core serializeJsonLd(value) helper, which
escapes <, >, &, U+2028, and U+2029 so authored strings cannot break out of
a <script type="application/ld+json"> tag. Rendering remains consumer-owned:
the Astro examples keep their own NexusSeo component. For content-driven site
identity (siteName, defaultImage) authored in a settings model, use the
service method nexus.resolvePageSeo(input, options?). Canonical URLs are joined
by Core's makeCanonicalUrl(baseUrl, pathname); the base URL itself stays
deployment-owned per project (loaded from PUBLIC_SITE_URL in the examples) —
Core never infers deployment URLs.
Key Concepts
- Framework neutral — Core never imports Astro, Next.js, React, or any frontend framework
- Normalized content — Every provider returns the same
PageContentandCollectionItemshapes - Model contracts —
schema.modelsdeclares sources and validates field data per logical model - Content provenance — Every result includes
meta.sourceandmeta.sourceId - Media — Provider-neutral
MediaAssetandMediaReferencewith local, remote, and WordPress resolution - Validation — Runtime schema validation with Zod; project-level schemas where practical
- Static first — Works at build time without a persistent server
Documentation
- ROADMAP.md — Milestones and release sequencing
- FEATURES.md — Feature status matrix
License
MIT
