@koehler8/cms
v1.3.0
Published
Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support
Maintainers
Readme
Vertex CMS
Vertex CMS is a lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support.
Accessibility
Vertex CMS targets WCAG 2.2 Level AA as a hard requirement for the bundled components, the base theme, and the page wrapper. Sites that ship on this framework with the bundled pieces inherit a skip link, single <main> landmark, real form labels with aria-live errors, visible focus rings, modal focus trap+restore, AA-verified palette contrast, and prefers-reduced-motion support out of the box.
When extending or theming, don't regress AA. See the "Accessibility" section of CLAUDE.md for the requirements, the token naming conventions for safe colors, and the audit checklist to run when changing palette/markup/CSS.
Quick Start
1. Create a site repo
my-site/
site/
content/
content.config.json # { "baseLocale": "en" }
en/
site.json # Site metadata and settings
shared.json # Shared content blocks
pages/
home.json # Per-page content
assets/img/ # Images and media
components/ # Site-local Vue components (optional)
style.css # Site-specific overrides (optional)
.env
package.json
vite.config.jsOr scaffold all of it in one step:
npx --package @koehler8/cms@^1.0.0 cms-create-site my-site2. Install
Vertex CMS is published to the public npm registry (registry.npmjs.org) — no scope configuration needed:
npm install @koehler8/cms@^1.0.0 vue vue-router vite vite-ssg @vitejs/plugin-vue3. Configure Vite
// vite.config.js
import cmsPlugin from '@koehler8/cms/vite';
export default {
plugins: [
cmsPlugin({
siteDir: './site',
themes: ['@koehler8/cms-theme-neon'],
extensions: ['@koehler8/cms-ext-compliance'],
}),
],
};4. Run
npm run dev # Development server
npm run build # Production build (SPA)
npx cms-ssg-build # Static site generation — memory-bounded shards + blank-page gate(vite-ssg build also works directly, but cms-ssg-build wraps it with a
bounded heap, route sharding for large sites, and a post-render check that
fails the build if any page comes out blank.)
Plugin Options
| Option | Default | Description |
|--------|---------|-------------|
| siteDir | './site' | Path to site content directory |
| frameworkRoot | (auto) | Path to @koehler8/cms package root |
| locales | all 15 supported | Array of locale codes to enable |
| themes | [] | Theme package names to register |
| extensions | [] | Extension package names to register |
| initialStateTrims | [] | Module paths exporting a hydration-payload trim hook (see below) |
Hydration payload
Each prerendered page embeds the config it needs as window.__INITIAL_STATE__
so the first client render reproduces the prerendered DOM instead of blanking
while the async loader runs. On a content-heavy site that blob can dominate
page weight, so there are two levers.
site.trimInitialState (default: on)
Embeds only the current page's entry from config.pages rather than every
page's, and marks the result pagesPartial. After hydration the full config is
fetched once in the background — deferred to requestIdleCallback, since the
current page has already rendered identically and the full config only matters
once the reader navigates.
Set it to false to embed the whole config. That flag is the single, complete
rollback for everything in this section.
initialStateTrims
trimConfigToPage handles config.pages. It cannot touch config.shared,
because knowing which shared content a route needs requires the site's own
notion of what that route resolves to. A site closes that gap with a hook:
// vite.config.js
cms({ initialStateTrims: ['./site/components/payloadTrim.js'] })// site/components/payloadTrim.js
export default function payloadTrim(config, { routePath, locale, pageId }) {
if (!needsTrimming(config)) return config; // same reference === no-op
return { ...config, shared: prune(config.shared, routePath) };
}The hook must be synchronous and pure — it runs once per prerendered route, which is thousands of times per build. Return the config unchanged (the same reference) to no-op; anything that is not a plain object is ignored.
The framework stamps sharedPartial: true when a hook actually changes
something, so a hook cannot forget the marker and ship a payload that is
permanently short with nothing to repair it. That marker triggers the same
post-hydration reload as pagesPartial, so anything pruned is restored before
it can be navigated to.
Never drop site, shared or pages wholesale. Framework components on
pages your hook never reasons about read them directly (useIntroGate,
FooterMinimal, ComingSoon, NotFound, Header), and a first client render
without them is a hydration mismatch on every page's chrome. Returns that do
so are discarded, as are hooks that throw.
Measured on a 767-page publication whose per-locale item bodies lived in
shared.json: payload 65.42 MB → 11.01 MB (−83.2%), gzipped HTML 23.22 MB →
7.23 MB (−68.9%).
Site Configuration
Site content lives in site/content/, organized by locale:
site/content/
content.config.json # { "baseLocale": "en" }
en/
site.json # Site metadata (flat dot-notation keys, sorted)
shared.json # Shared content (header, footer, socials)
pages/
home.json # Per-page content
privacy.json
de/
site.json # German overrides (only translated keys)
shared.json
pages/
home.jsonAll files use flat dot-notation keys in alphabetical order. The base locale (specified in content.config.json) is loaded first; other locale directories override only the keys they specify. Missing keys fall back to the base locale value.
Supported locales: en, fr, es, de, ja, ko, pt, ru, tr, vi, id, zh, th, hi, fil
Analytics & consent
Set "googleId" in site.json to enable Google Analytics. How the
not-yet-answered consent state is treated is configurable:
{ "analytics.consentMode": "opt-in" }"opt-out"(default) — analytics load while consent is pending and stop if the visitor declines. Common US posture."opt-in"— analytics stay off until the visitor explicitly accepts. Use this for sites with EU/EEA audiences (GDPR / ePrivacy).
Themes
Themes export a manifest with design tokens (palette, typography, surfaces, CTAs, etc.) that are applied as CSS variables at runtime.
- Set the
themekey incontent/{baseLocale}/site.jsonto a theme slug - Omit it to use the built-in
basetheme - External themes are npm packages registered via the
themesplugin option
See themes/base/theme.config.js for the full token structure.
Extensions
Extensions are npm packages that provide additional components, content defaults, and setup hooks.
Each extension has an extension.config.json manifest defining:
- components -- Vue components with metadata (name, configKey, allowedPages, requiredContent)
- entry / setup -- Optional initialization hooks
- assets -- CSS and static file references
- dependencies -- Required npm packages
The manifest JSON Schema ships with the package — point your editor or JSON-schema tooling at it:
{ "$schema": "node_modules/@koehler8/cms/extensions/manifest.schema.json" }Built-in Components
| Component | Description |
|-----------|-------------|
| Header | Site header with navigation |
| Footer / FooterMinimal | Full and minimal footer variants |
| Hero | Hero banner section |
| About / AboutValue | About and value proposition sections |
| Contact | Contact form (Google Forms backend) |
| Team | Team member grid |
| Portfolio | Portfolio showcase |
| Plan | Pricing/plan comparison |
| Principles | Principles/values section |
| Intro / IntroGate | Intro modal and gate |
| ComingSoon / ComingSoonModal | Coming soon page and modal |
| StickyCTA | Sticky call-to-action bar |
| BackToTop | Scroll-to-top button |
| Preloader | Page loading indicator |
| Spacer15/30/40/60 | Vertical spacing utilities |
UI Components
SbCard-- Card componentSkeletonPulse-- Loading skeletonUnitChip-- Unit/badge chip
Composables
| Composable | Description |
|------------|-------------|
| usePageConfig | Load and cache page configuration |
| useComponentResolver | Resolve and validate component definitions |
| usePageMeta | Apply head/meta tags via @unhead |
| useEngagementTracking | Scroll depth and engagement analytics |
| useIntroGate | Intro modal state management |
| useComingSoonConfig | Coming-soon page configuration |
| useLazyImage | Lazy image loading with IntersectionObserver |
| usePromoBackgroundStyles | Promo section background styling |
Draft Mode
Pages can be gated behind a site-wide password before launch: "draft": true
on a page, "draftPaths": [...] prefixes, or "draft": true site-wide in
site.json, with the password in "draftPassword". The build replaces the
plaintext password with a SHA-256 hash (plaintext never ships), gated pages
render only the password gate in the SSG HTML, are marked noindex, and are
omitted from sitemap.xml.
Know what the gate is — and isn't. Draft mode is a pre-launch convenience, not access control:
- The password hash ships in the public bundle (unsalted SHA-256 — a weak or guessable password is trivially recoverable).
- The gated page's
content.*keys still ship in the page's hydration JSON so Vue can mount after unlock — a determined reader can parse them out of the HTML without the password.
For genuinely confidential content (NDA material, unannounced listings), don't put it in the page JSON until launch.
Testing
The framework uses Vitest with happy-dom for unit testing.
npm test # Run all tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage reportTests live in tests/ mirroring the source structure (tests/utils/, tests/composables/, tests/themes/, etc.). See vitest.config.js for configuration.
CLI Commands
# Scaffold a new site / theme / extension
npx cms-create-site my-site
npx cms-create-theme my-theme
npx cms-create-extension my-extension
# Static site generation (memory-bounded shards + blank-page gate)
npx cms-ssg-build
# Generate favicon.ico, logo.png, og-image.jpg from source assets
npx cms-generate-public-assets --site-dir ./site
# Validate theme manifests (bundled + your site-local themes/)
npx cms-validate-themes --site-dir ./site
# Validate extension manifests (site-local extensions/ + named packages)
npx cms-validate-extensions --site-dir ./site @koehler8/cms-ext-compliance(The Vite plugin also runs the same extension-manifest validation on every build and fails loudly on an invalid manifest — the CLIs are for checking outside a build.)
Exports
The supported public API surface — these specifiers are covered by semver:
// Build integration
import cmsPlugin from '@koehler8/cms/vite'; // Vite plugin
import { createCmsApp } from '@koehler8/cms/app'; // App factory
// Utils commonly used by site components and extensions
import { loadConfigData } from '@koehler8/cms/utils/loadConfig';
import { resolveAsset, resolveMedia } from '@koehler8/cms/utils/assetResolver';
import { useResponsiveImage } from '@koehler8/cms/utils/imageSources';
import { trackEvent } from '@koehler8/cms/utils/analytics';
import { hasAcceptedConsent } from '@koehler8/cms/utils/cookieConsent';
import { formatTokenAmount } from '@koehler8/cms/utils/formatNumber';
import { resolveThemeColor } from '@koehler8/cms/utils/themeColors';
// Bundled components and composables (by file)
import Header from '@koehler8/cms/components/Header.vue';
import { useComingSoonConfig } from '@koehler8/cms/composables/useComingSoonConfig';The extension manifest schema is exported at
@koehler8/cms/extensions/manifest.schema.json.
Other utils/*, composables/*, components/*, themes/*, and
extensions/* deep imports resolve too, but anything not listed above is
internal — it may move or change between minor versions.
Environment Variables
| Variable | Used By | Description |
|----------|---------|-------------|
| VITE_SHOW_COOKIE_BANNER | cookieConsent | Enable cookie consent banner |
| VITE_APP_VERSION | appInfo | Override the framework version string reported in the bundle |
| CMS_SITE_DIR | generate-public-assets | Site directory path (build scripts) |
| FAVICON_BG / FAVICON_FG | generate-public-assets | Fallback favicon/og colors (default: stable per-title pair) |
License
MIT
