npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@velastack/cms

v0.5.1

Published

A scope-aware CMS for SvelteKit, for static or dynamic sites.

Readme

VelaStack CMS

A scope-aware CMS for SvelteKit, for SSG and fullstack websites.

<!-- src/routes/(marketing)/about/+page.svelte -->
<script>
	import { CmsText, CmsRichText } from '@velastack/cms';
</script>

<h1>
	<CmsText name="hero.title" fallback="About us" />
</h1>

<CmsRichText name="body" />

That's the whole authoring API. No field paths, no manual wiring. The build-time Vite plugin discovers every <CmsText/> (and friends) reachable from each route, computes which route scope they belong to, and emits a manifest. At request time, loadCms(event, …) resolves the right documents for the current route. At edit time, the admin bar swaps display components for inline editors — without shipping a single byte of editing code to public visitors.

Why scope-aware?

A reusable component like Header.svelte may be mounted from (marketing)/+layout.svelte and (app)/+layout.svelte. Its <CmsText name="header.title" /> is the same field name in both places, but those should be different stored values — one for the marketing site, one for the app shell.

CMS identity therefore can't be (component file + field name). It has to be (usage scope + field name). The plugin walks each +layout.svelte / +page.svelte's static import graph, attributes every CMS field reference to the route entrypoint that reached it, and emits:

{
  '/(marketing)/about': {
    scopes: [
      { scopeId: 'layout:/',                 fields: ['footer.links'] },
      { scopeId: 'layout:/(marketing)',      fields: ['header.title', 'announcement.text'] },
      { scopeId: 'page:/(marketing)/about',  fields: ['hero.title', 'body'], metadata: ['title', 'description', 'canonical', 'robots'] }
    ]
  },
  …
}

Install

npm install @velastack/cms

Quick start

1. Register the Vite plugin

// vite.config.ts
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { cms } from '@velastack/cms/vite';

export default defineConfig({
	plugins: [cms(), sveltekit()]
});

The plugin reads src/routes/ and src/lib/ by default. Override with cms({ routesDir, libDir }) if your project is laid out differently.

2. Create the CMS in $lib/cms.ts

// src/lib/cms.ts
import { createCms, mockAdapter } from '@velastack/cms/server';

export const { load: loadCms, generateEntries } = createCms({
	adapter: mockAdapter(),
	locales: ['en'] // or ['en', 'es', 'fr'] — first entry is the default locale
});

Swap mockAdapter for your real adapter once you have a backend (see Adapters). The locales array names every BCP-47 locale your site supports; the first entry is the default locale used for read-time fallback when a value is missing in the requested locale (see Locales).

3. Wire loadCms into the root +layout.server.ts

Optionally include svelte-meta-tags for easy page metadata handling, but it isn't a requirement.

// src/routes/+layout.server.ts
import { error, redirect, type ServerLoad } from '@sveltejs/kit';
import { defineBaseMetaTags } from 'svelte-meta-tags';
import { loadCms } from '$lib/cms.js';

export const load: ServerLoad = async (event) => {
	const { baseMetaTags } = defineBaseMetaTags({
		title: 'My site',
		titleTemplate: '%s · My site'
	});

	// Pick the locale however you like — pathname segment (`/es/about`),
	// `Accept-Language`, a session cookie, etc. Pass whatever string you
	// land on; the loader doesn't care how it was derived.
	const locale = event.url.searchParams.get('locale') ?? 'en';

	const { cms, notFound, gone, redirectTo } = await loadCms(event, { locale });
	if (redirectTo) redirect(308, redirectTo);
	if (gone) error(410, 'Gone');
	if (notFound) error(404, 'Not found');

	return {
		baseMetaTags,
		cms
	};
};

loadCms returns four mutually-exclusive page-kind resolutions, in priority order:

| Field | Meaning | Caller does | | ------------ | ---------------------------------------------------------------------------------- | ------------------- | | redirectTo | Page was replaced with a permanent redirect to this URL. | redirect(308, …) | | gone | Page was deliberately, permanently removed. | error(410, …) | | notFound | Page-kind scope had owned params and the adapter returned no doc and no tombstone. | error(404, …) | | (none set) | Render normally with data.cms. | return { cms, … } |

Layout scopes never tombstone — only page-kind scopes can resolve to gone / redirectTo.

4. Render <AdminBar/> in the root layout

Along with optional <MetaTags /> handling.

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { MetaTags, deepMerge } from 'svelte-meta-tags';
	import { AdminBar, cms } from '@velastack/cms';

	let { data, children } = $props();
	let metaTags = $derived(deepMerge(data.baseMetaTags, cms.metadata));
</script>

<AdminBar />
<MetaTags {...metaTags} />

{@render children()}

<AdminBar/> is a tiny sync wrapper. Its UI and every editable component are dynamically imported, so public visitors never download editing code.

5. Author your routes

<!-- src/routes/(marketing)/about/+page.svelte -->
<script lang="ts">
	import { CmsText, CmsRichText } from '@velastack/cms';
</script>

<h1>
	<CmsText name="hero.title" fallback="About us" />
</h1>

<CmsRichText name="body" />

That's it — the plugin handles scope and the loader handles data fetching.

Production builds: prerender + media download

loadCms is server-only — call it from +layout.server.ts (or +page.server.ts), never from a +page.svelte or universal +page.ts. Calling it in the browser throws. Editing/preview happens through cmsStore from @velastack/cms, which is gated by your cms_session cookie (scoped to the CMS mount path, so sites sharing an origin hold independent sessions) and opaque preview/version keys.

The recommended production shape is prerender by default:

// src/routes/+layout.server.ts
export const prerender = true;

When the customer app is also built against a real backend, configure the Vite plugin with the same endpoint your apiAdapter uses:

// vite.config.ts
import { cms } from '@velastack/cms/vite';

export default defineConfig({
	plugins: [
		cms({
			endpoint: 'https://cms.example.com/v1/projects/p1/cms',
			locales: ['en', 'es', 'fr']
		}),
		sveltekit()
	]
});

With endpoint set, vite build:

  1. Walks every locale's published content over HTTP and collects every /uploads/<file> URL it sees in cms.docs trees and entry metadata.
  2. Downloads each unique file into static/cms-media/<file> (override with mediaDir). Files already on disk are skipped — second builds are fast.
  3. While SvelteKit prerenders, the apiAdapter rewrites those same URLs to /cms-media/<file> (override with mediaPrefix) inside the responses it hands back. Prerendered HTML/JSON references local paths only — the CMS backend stays off the production hot path.

Only URLs that flow through apiAdapter responses are rewritten. If you build a media URL by hand (e.g. <img src={${cms.endpoint}/uploads/${slug}.png}>), it stays pointing at the backend — read it through <CmsImage/> or cms.docs[scope][field].url to get the local path.

@sveltejs/enhanced-img interop: since media lands in static/, you can reference specific images by static path (e.g. <enhanced:img src="/cms-media/hero.webp" />) and get all the usual <picture>/srcset benefits. Fully dynamic enhanced-img on CMS-driven images isn't built in.

If endpoint is omitted (e.g. when using mockAdapter for tests/demos), the plugin's media steps are no-ops — builds proceed exactly as before.

CMS components

Every CMS component shares the same prop shape:

| Prop | Type | Notes | | ---------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | name | string | Field path inside this scope. Must be a static string at v1. | | scope | 'root' \| string | Read and write a value that lives in the root layout scope ('root', i.e. layout:/) or another param-less layout scope (layout:/(public)). | | fallback | varies | Rendered when nothing is stored. string for text, HTML for rich text, { url, alt } or a URL for images. | | value | unknown (opt-in) | Per-item override; bypasses scope lookup and disables editing. |

Two stored states are distinct: undefined (nothing stored) renders the fallback, null (the editor cleared the field) renders empty. Editables write null on clear, never drop the key, so a cleared field can't fall back to the template's demo copy after publish.

scope="root" is how site-wide content — hours, contact, navigation, branding — is edited where it renders: put the component in the root +layout.svelte and reference the same name from any page with scope="root". The payload already carries every enclosing layout tree, so no extra fetch happens.

<CmsText />

<CmsText name="hero.title" fallback="About us" />

Plain text. In edit mode, swaps for an inline <input> bound to the draft store.

<CmsRichText />

<CmsRichText name="body" fallback="<p>Coming soon.</p>" />

Rendered with {@html}. Edit mode swaps for a <textarea>.

<CmsImage />

<CmsImage name="hero.image" alt="Hero" />

Renders an <img>. Edit mode shows the current image plus a URL input.

Structured components

Hours, navigation, a team, a price list: one value with a fixed shape, edited in a slot-anchored popover and rendered by the template through a snippet. Every structured component is headless: it reads the value at name, normalizes it, derives the view a template needs and hands it to children. It never emits markup of its own.

<script lang="ts">
	import { CmsHours, CmsNav, isActive } from '@velastack/cms';
</script>

<!-- In the root layout: site-wide by construction. -->
<CmsNav name="nav.primary">
	{#snippet children(items)}
		{#each items as item (item.id)}
			<a href={item.href} aria-current={isActive(item, page.url) ? 'page' : undefined}
				>{item.label}</a
			>
		{/each}
	{/snippet}
</CmsNav>

<!-- On the contact page: the same value, read and written through the root scope. -->
<CmsHours name="hours" scope="root">
	{#snippet children(h)}
		<p>{h.today.label}: {h.today.text} · {h.status}</p>
		{#each h.rows as row (row.id)}<dt>{row.days}</dt>
			<dd>{row.text}</dd>{/each}
	{/snippet}
</CmsHours>

| Component | Value | Snippet receives | Helpers | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------- | | CmsNav | { items: [{ id, label, link, children }] } | items with resolved href | isActive(item, url) | | CmsHours | week grid, exceptions, note, labels, timezone | rows, today, isOpenNow, status, exceptionRows in the visitor's locale | toOpeningHoursSpecification | | CmsContact | name, address, phones, emails, whatsapp, directionsUrl, geo, labels | plus telHref, mailto, whatsappHref, mapsHref, formattedAddress | toPostalAddress | | CmsSocialLinks | { items: [{ id, platform, url, label }] } | items with name (label or platform name) | toSameAs, SOCIAL_PLATFORMS | | CmsCollection | rooms, services, tours: title, summary, body, image, gallery, price, link, tags, features, details key-value list, featured, hidden | visible items with href; props limit, featured, tag slice them | formatCollectionPrice, collectionTags, toItemList | | CmsTeam | name, role, rich bio, photo, email, phone, profile links | members | | | CmsTestimonials | rich quote, author, role, company, photo, rating, source, date | testimonials | toReview | | CmsPricing | currency, labels.custom, tiers with a feature checklist and a CTA | tiers with priceText and href | formatPrice | | CmsFaq | question, rich answer | questions | toFaqPage | | CmsStats, CmsSteps, CmsTimeline, CmsGallery, CmsLogos, CmsSchedule | fixed list presets | items | logosView, scheduleByDay |

Templates use only these exports; there is no open item schema. A shape outside the presets is a new preset in the package. fallback takes the full value or a bare item list and is normalized like a stored value, so a hand-written seed with missing fields is safe. Prices, times, dates and day names format in the visitor's locale; isOpenNow is computed when the page renders, so a prerendered page shows the state at build time.

Every value type (CmsHoursValue, NavItem, …), schema (cmsHours, …) and JSON-LD helper is exported from the package root.

Building one

The package ships the pieces a structured component is made of, and the components above are built from nothing else.

// The value's rules: a versioned shape, its translatable strings, and the form the popover draws.
import { defineForm, asItems, asString } from '@velastack/cms';

export const cmsFaq = defineForm<CmsFaqValue>({
	component: 'CmsFaq', // canonical export name, recorded in the manifest
	label: 'FAQ', // popover header
	version: 1, // written as `v` on every value
	translatable: [], // root fields that translate (dotted paths allowed: 'labels.closed')
	items: { key: 'items', translatable: ['question', 'answer'] }, // lists and their translatable fields; nest `items` for lists inside items
	normalize: (raw) => ({ v: 1, items: asItems(raw?.items, …) }),
	empty: () => ({ v: 1, items: [] }),
	fields: [{ key: 'items', type: 'list', label: 'Questions', itemLabel: 'question', titleKey: 'question', blank: () => ({ question: '', answer: '' }), fields: [
		{ key: 'question', label: 'Question', type: 'text' },
		{ key: 'answer', label: 'Answer', type: 'html' }
	] }]
});
<!-- The display component, inside the package: the generic one, given the schema and a view function. -->
<script lang="ts">
	import Structured from './cms-structured.svelte'; // src/lib/components/cms, inside the package
	let { name, scope, fallback, value, children } = $props();
</script>

<Structured schema={cmsFaq} {name} {scope} {fallback} {value} view={faqView} {children} />

Field types: text, long-string, html (TipTap), number, boolean, date, time, url, image and images (media picker, alt text), enum, link (page picker or URL, new tab), strings (one per line), rating, list (collapsible cards with reorder). group puts a field in a tab; half shares a row with the next field. A bespoke editor (the hours week grid) wraps StructuredEditorPopover from @velastack/cms/editor and renders FieldsForm for the rest; FieldInput, ListField, LinkInput, RichTextInput, ReorderButtons and the Popover / Tabs / Switch / Checkbox / Sheet primitives are exported there too. The popover commits on Done; edit mode is page-wide and the bar's Save flushes the draft.

Rules every structured value follows (core/structured.ts):

  • Whole-object writes, explicit null clears. Components never patch a leaf; they write the full { v, … } object. Cleared fields are null.
  • List items carry a stable id (newItemId()) at every nesting level; reorder and delete work because arrays replace wholesale on merge.
  • Localisation is a string overlay. The default locale stores the full value at <name>; every other locale stores only strings at <name>.$t.<id>.<field> (_ is the id for root fields, field may be a dotted path, and a string array translates per index as tags.0). read() folds the overlay in, structure never forks between languages, and the popover's locale tabs write only the overlay. The Locales panel counts translatable strings per locale for every structured usage on the current route.

<CmsEntries />

<script lang="ts">
	import { CmsEntries } from '@velastack/cms';
</script>

<CmsEntries routeId="/(marketing)/rooms/[slug]">
	{#snippet children(entry)}
		<li>
			<a href={resolve('/(marketing)/rooms/[slug]', entry.params)}>
				{entry.metadata.title}
			</a>
		</li>
	{/snippet}
</CmsEntries>

Iterates over a set of pages by route ID. Useful for index pages.

Custom CMS components

VelaStack CMS discovers your custom or third-party CMS components by convention.

Auto-discovery (zero config)

A component is treated as a CMS component if any of the following hold:

  1. It lives in your app's src/lib/components/cms/. Drop a new .svelte file there and it's registered automatically:

    <!-- src/lib/components/cms/cms-link.svelte -->
    <script lang="ts">
    	import { CmsText } from '@velastack/cms';
    	let { name, fallback, value } = $props();
    </script>
    
    <a href={typeof value === 'string' ? value : undefined}>
    	<CmsText name={`${name}.label`} {fallback} />
    </a>

    Use it from any route: <CmsLink name="hero.cta" />. The plugin picks up hero.cta as a field on the page's scope; no plugin config edit required.

  2. It's a named export from your local src/lib/components/cms/index.{ts,js} barrel. Re-export your component there if you prefer a single import path..

  3. It's imported from the @velastack/cms package itself. Every named import from @velastack/cms is a CMS component, whether or not the bundler resolved the package.

Third-party packs (opt-in)

For CMS components published in npm packages outside @velastack/cms, declare them with the plugin's components option:

// vite.config.ts
import { cms } from '@velastack/cms/vite';

cms({
	components: [
		// Named exports — `import { Hero, Quote } from 'my-cms-pack'`
		{ source: 'my-cms-pack', names: ['Hero', 'Quote'] },
		// Single-file default export — `import Accordion from 'my-cms-pack/accordion.svelte'`
		{ source: 'my-cms-pack/accordion.svelte', default: true }
	]
});

Listed sources are auto-registered for traversal; the plugin walks .svelte files inside those packages to discover any further CMS usages they contain.

If you want the walker to descend into a package whose .svelte files contain CMS usages but aren't themselves CMS components (a wrapper / design-system scenario), add it to traverse:

cms({
	traverse: ['my-design-system', /^@my-org\//]
});

String patterns match source === pattern || source.startsWith(pattern + '/'). RegExp is the escape hatch — use it sparingly; matching too broadly will pull arbitrary node_modules .svelte files into the walk.

Component contract

A CMS component should:

  • Accept { name: string; scope?: string; fallback?: …; value?: unknown } props (and any extras you need).
  • Call useCmsField(() => ({ name, scope, value }), read) once at init. It resolves the scope (context, 'root', or an explicit id), reads the merged value, reports editable, and exposes set / clear.
  • Treat undefined as "render the fallback" and null as "render empty" in read.
  • Optionally provide an editable sibling that's dynamically import()'d when field.editable flips on, so editing code doesn't ship to public visitors.

The built-ins are reference implementations; copy src/lib/components/cms/cms-text.svelte (or one of the others with a paired *-editable.svelte sibling) as a starting point.

Architecture

Scopes

A scope is { kind, routeId, ownedParams }. There are two kinds:

  • layout:/(marketing) — content the marketing layout uses. Shared across every page rendered through (marketing).
  • page:/(marketing)/rooms/[slug] — page-specific content. With ownedParams: ['slug'], the runtime composes scope keys per slug: page:/(marketing)/rooms/[slug]?slug=suite-1.

A request through /(marketing)/rooms/suite-1 produces three scope queries (root layout, marketing layout, page) and the adapter resolves all three. The runtime returns one merged CmsPayload:

{
  locale: 'en',                  // resolved locale for this request
  locales: ['en', 'es', 'fr'],   // supported set, default = first entry
  docs: {
    'layout:/(marketing)': { 'header.title': '…' },
    'page:/(marketing)/rooms/[slug]?slug=suite-1': { 'hero.title': '…' }
  },
  scopes: { /* one entry per scopeKey, with kind/routeId/params/fields/metadata */ },
  metadata: { title: '…', description: '…' } // page-scoped only
}

Components read from this via getContext(CMS_SCOPE) + page.data.cms.

Auto-injected scope context

The Vite plugin transforms every +layout.svelte and +page.svelte by inserting:

import { installCmsScope } from '@velastack/cms';

installCmsScope({
	scopeId: 'page:/(marketing)/rooms/[slug]',
	kind: 'page',
	routeId: '/(marketing)/rooms/[slug]',
	ownedParams: ['slug']
});

installCmsScope synchronously seeds the scope from page.params (so SSR is correct) and keeps scopeKey in sync via $effect (so client-side navigations between sibling param values update reactively).

Edit mode

cmsStore is a runes-backed singleton:

  • isEditing: boolean
  • drafts: Record<scopeKey, Record<fieldName, value>>
  • metadataDrafts: Record<scopeKey, Record<metaKey, value>>
  • setValue / getValue / hasDraft
  • setMetadataValue / getMetadataValue / hasMetadataDraft
  • save() — currently logs to the console (replace with a write-back call once you wire your adapter)
  • clearDrafts()

Display components prefer drafts over published values when isEditing is true. Editable variants (*-editable.svelte) and the SEO panel are dynamic-import-only chunks; nothing edit-related ships in the public bundle.

Page metadata

Every page scope carries the full metadata set by default — title, description, ogTitle, ogDescription, ogImage, twitterCard, canonical, noindex — and a route's page.cms.ts metadata schema adds to or overrides it. The SEO panel previews the search snippet and the share card; blank share fields reuse the search fields. Resolution order, per design:

editable page metadata (cms.metadata)
  → static route/template defaults (+page.ts pageMetaTags)
  → static site defaults (defineBaseMetaTags)

toMetaTags(cms.metadata, { siteName }) turns the branch into svelte-meta-tags props, with the title template and Open Graph site name from the root layout's branding.name:

<script lang="ts">
	import { cms, toMetaTags } from '@velastack/cms';
	const siteName = $derived(cms.docs['layout:/']?.branding?.name as string | undefined);
	const metaTags = $derived(deepMerge(data.baseMetaTags, toMetaTags(cms.metadata, { siteName })));
</script>

Site options

createCms({ site }) declares the Site Options panel: project-wide values that never render on the page — the schema.org type (enum), the default share image (image), which locales are enabled (boolean). Field types are text, markdown, number, datetime, url, color, image, enum (with values) and boolean. Anything a visitor sees belongs in the root layout scope, edited in place with scope="root"; the site tree is one non-localised tree per project.

Adapters

export interface CmsAdapter {
	readonly endpoint?: string;

	fetchDocs(
		queries: CmsScopeQuery[],
		context: CmsAdapterContext
	): Promise<Record<string, CmsAdapterResolution>> | Record<string, CmsAdapterResolution>;

	fetchEntries(routeId: string, context: CmsAdapterContext): Promise<CmsEntry[]> | CmsEntry[];
}

type CmsAdapterContext = {
	fetch: typeof fetch; // SvelteKit's request-scoped fetch
	previewKey?: string | null; // ?preview= — overlay an open release's pending edits
	versionKey?: string | null; // ?version= — load a past published release snapshot
	locale: string; // BCP-47 bound at loadCms({ locale }) time
	locales: string[]; // supported set; first entry is the default locale
};

type CmsScopeQuery = {
	scopeId: string;
	kind: 'layout' | 'page';
	routeId: string;
	params: Record<string, string>; // owned params for this scope only
	fields: string[];
	locale: string; // request locale, repeated per query for adapter convenience
};

type CmsAdapterResolution = CmsAdapterDoc | CmsAdapterTombstone;
type CmsAdapterDoc = { contents: Record<string, unknown> };
type CmsAdapterTombstone = { kind: 'gone' } | { kind: 'redirect'; to: string };

Each CmsScopeQuery carries the composed scopeId, the kind ('layout' | 'page'), the route id, the resolved owned params, the field list, and the locale. Map those to backend reads however you want. The context.fetch argument is the SvelteKit request-scoped fetch for HTTP-backed adapters.

Page-kind docs own a metadata branch on the doc tree. loadCms aliases that branch onto cms.metadata (for definePageMetaTags(...)) without removing it from the doc — components addressing metadata.title etc. read straight through.

A page-kind scope can also resolve to a tombstone ({ kind: 'gone' } or { kind: 'redirect', to }) instead of a doc. The loader short-circuits the page render and surfaces gone / redirectTo on the LoadCmsResult so the caller can return 410 / 308. Layout scopes never tombstone.

fetchEntries enumerates publishable entries at one route id (used by generateEntries for prerender). Tombstoned entries are returned with redirectTo / gone flags so prerender visits the URL and SvelteKit emits the redirect file; the loader filters them out of cms.entries before display.

mockAdapter (built-in)

Useful for tests, demos, and pre-backend development. Storage is keyed by [locale][routeId]:

import { mockAdapter } from '@velastack/cms/server';

const adapter = mockAdapter({
	layoutDocs: {
		en: { '/(marketing)': { header: { title: 'Climb Angola' } } },
		es: { '/(marketing)': { header: { title: 'Escalar Angola' } } }
	},
	pageDocs: {
		en: {
			'/(marketing)/about': [
				{
					params: {},
					published: {
						hero: { title: 'About us' },
						metadata: { title: 'About us' }
					}
				}
			]
		}
	}
});

A page can be tombstoned at publish time by setting tombstone on its PageEntry:

mockAdapter({
	pageDocs: {
		en: {
			'/(marketing)/rooms/[slug]': [
				// Hard 410 — this URL is permanently gone.
				{ params: { slug: 'old-suite' }, published: {}, tombstone: { kind: 'gone' } },
				// Permanent redirect — visitors land on the new URL.
				{
					params: { slug: 'legacy' },
					published: {},
					tombstone: { kind: 'redirect', to: '/rooms/suite-1' }
				}
			]
		}
	}
});

Pass resolvePreview to enable release-preview overlay; release items can also stage tombstones in flight via outcome on a page-delete item:

{
  kind: 'page-delete',
  routeId: '/(marketing)/rooms/[slug]',
  params: { slug: 'legacy' },
  locale: 'en',
  outcome: { kind: 'redirect', to: '/rooms/suite-1' } // omit for hard delete (404 after publish)
}

Writing your own adapter

A skeleton sketch:

import type { CmsAdapter, CmsAdapterResolution } from '@velastack/cms/server';

export const myAdapter = (config: { project: string; client: MyClient }): CmsAdapter => ({
	async fetchDocs(queries, { fetch, locale, previewKey }) {
		const rows = await config.client.findMany({
			project: config.project,
			keys: queries.map((q) => ({
				scopeId: q.scopeId,
				routeId: q.routeId,
				params: q.params,
				locale: q.locale
			})),
			previewKey,
			fetch
		});
		const out: Record<string, CmsAdapterResolution> = {};
		for (const r of rows) {
			out[r.scopeId] = r.tombstone ?? { contents: r.contents };
		}
		return out;
	},
	async fetchEntries(routeId, { fetch, locale }) {
		const rows = await config.client.listEntries({
			project: config.project,
			routeId,
			locale,
			fetch
		});
		return rows.map((r) => ({
			params: r.params,
			metadata: r.metadata ?? {},
			...(r.redirectTo ? { redirectTo: r.redirectTo } : {}),
			...(r.gone ? { gone: true as const } : {})
		}));
	}
});

The adapter doesn't need to implement default-locale fallback — resolveCmsPayload issues a parallel default-locale fetchDocs when needed and merges trees on the loader side. Just return what's stored for the requested locale.

Locales

Locale support is first-class and lives at the release / version dimension: every release contains items across every locale you've touched, so publishing ships all locales atomically. Items are tagged (scope, locale, tree); switching the editor's preview locale is a free UI op against the same release.

Configuration: pass the supported set to createCms({ locales }). The first entry is the default locale.

Per-request: the consumer decides how to derive a locale from the request (pathname, Accept-Language, cookie, query param, …) and passes the resulting string to loadCms(event, { locale }). The library doesn't read URLs itself.

Default-locale fallback: when locale !== locales[0], the loader fetches docs in both locales and merges (mergeTree(default, requested)), so a missing hero.title in es falls through to the EN value. Arrays replace wholesale — explicit ES content always overrides EN, but absent ES keys inherit. Page-kind tombstones in either locale apply (requested wins on conflict).

Editor preview locale: the admin bar uses ?locale= to override whichever locale the consumer's logic would have picked, so editors can preview any locale at any URL without changing pathname routing. The bar's Locales panel shows per-locale edit counts (workingCopyCountsByLocale) plus translation gaps (default-locale items not yet edited in each other locale).

On the payload:

cms.locale; // 'es'
cms.locales; // ['en', 'es', 'fr']

Seeding a project

A fresh project should open with content, not a blank site. The backend accepts a project's whole published state as one object and returns it in the same shape:

type CmsSeed = {
	layouts?: Record<locale, Record<routeId, Tree>>;
	pages?: Record<locale, Record<routeId, Tree | PageEntry[]>>; // a bare tree is a static page
	site?: Tree;
};
  • POST /seed with a CmsSeed (plus force?: true) writes the published rows directly, outside the release flow. It answers 409 { ok: false, reason: 'already-seeded' } when the project already has published rows unless force is set, so it can never silently overwrite edits. Image values are seeded as URLs; nothing is uploaded.
  • GET /export returns the published rows as a CmsSeed (tombstoned pages omitted).
  • In-process: backend.store.seed(projectId, seed, { force }) and backend.store.exportPublished(projectId).

Both routes require an editor session. A host that creates projects (velastack.dev's /setup) reads the template's published content/ and calls store.seed right after creating the project, in the locales the owner enabled.

Redirects & tombstones

Pages can be deleted with three different outcomes:

| Outcome | HTTP | When to use | | ------------------- | ---- | ------------------------------------------------------------ | | notFound (no doc) | 404 | Default — the URL just isn't there. | | gone | 410 | URL was deliberately retired; tell crawlers to forget it. | | redirectTo | 308 | URL moved permanently; preserve link equity to the new path. |

Tombstones live on the adapter's PageEntry (tombstone: CmsAdapterTombstone) for already-published deletions, and on a release's page-delete item (outcome?: CmsAdapterTombstone) for staged deletions in the editor's working copy. The loader returns whichever applies on LoadCmsResult.{ gone, redirectTo }; your +layout.server.ts branches on those flags before rendering.

For prerender, fetchEntries returns tombstoned entries with redirectTo / gone flags so SvelteKit visits the URL and emits the redirect file — but cms.entries[routeId] strips them, so <CmsEntries> lists never display deleted pages.

Plugin options

cms({
	routesDir: 'src/routes', // path to SvelteKit routes (default)
	libDir: 'src/lib', // path to project lib (default)
	components: [
		// third-party CMS packs — see "Custom CMS components"
		{ source: 'my-cms-pack', names: ['Hero', 'Quote'] }
	],
	traverse: [
		// bare specifiers whose .svelte files should be walked
		'my-design-system'
	],

	// Production build options — see "Production builds" above.
	endpoint: 'https://cms.example.com/v1/projects/p1/cms',
	locales: ['en'], // mirror createCms({ locales })
	mediaDir: 'static/cms-media', // download target (default)
	mediaPrefix: '/cms-media', // URL prefix in rewritten content (default)

	// Content manifest: `content/<locale>.json` keyed by scope id. When the
	// default locale's file exists its values are injected as `fallback`
	// props into every walked .svelte file, so components stay name-only.
	content: 'content' // default; `false` disables
});

Build tooling that needs the same parser — a pack step inlining fallbacks into a tarball, a verify step checking usages against a content manifest — imports it from @velastack/cms/build: scanUsages(source), injectFallbacks(source, treeOrResolver) / inlineFallbacks, buildManifest(...) and fallbackResolverFor(result, file, content). The manifest records each usage's component type (scope.usages), and the walk reports dynamic names and out-of-chain scope= attributes as warnings instead of dropping them silently.

The plugin also exposes virtual:vela-cms/manifest (typed via the package's ambient declaration). You almost never need to import it directly — loadCms does that internally — but it's there if you want to introspect the manifest at build time.

Constraints (v1)

Static analysis is conservative on purpose:

  • name= props must be string literals or name={'literal'}. Computed names are not extracted; the plugin warns about each one.
  • Only static import of .svelte files is followed when walking the component graph. Bare specifiers (npm packages) are skipped unless they're the @velastack/cms package itself, are listed under components, or match a traverse pattern.
  • <svelte:component this={…} /> and dynamic component selection are not traced.
  • Layout reset segments ([email protected]) are not yet supported.

Repeater item internals can be edited via the per-key inputs the editable variant emits; nested <CmsText name="…" value={item.x} /> overrides stay non-editable by design.

Project layout

plugin/src/      Vite plugin (route discovery, AST parse, manifest builder, transform)
src/lib/         Public library
  components/cms/      Display + editable components, scope helpers, store
  components/admin-bar/ Admin bar wrapper + async internal + SEO panel
  server/              loadCms, mockAdapter, types
src/routes/      Test harness / showcase

License

MIT