@csirt-cms/widget-core
v0.1.1
Published
Framework-agnostic widget framework for the Central Content Management Service: registry, schemas, defaults, visibility, migrations and Public API client.
Downloads
306
Maintainers
Readme
@csirt-cms/widget-core
The framework-agnostic half of the CSIRT CMS widget system: widget definitions, the registry, validation, defaults, visibility rules, migrations, import/export, the Public API client and theming.
It contains no UI code. The React and Vue packages both build on it, which is why a widget's schema, defaults and version are written once and behave identically everywhere.
npm install @csirt-cms/widget-corezod is a dependency; the framework packages declare it as a peer so one copy is
shared with your CMS admin app.
The Public API client
The CMS is headless. GET /api/v1/public/sites/{slug}/page/{pageSlug} returns a
page as { site, page, seo, sections }, where each section is a
{ component, props } pair — the service never renders HTML.
import { createCmsClient } from "@csirt-cms/widget-core";
const client = createCmsClient({
baseUrl: "https://cms.jogjaprov.go.id",
site: "csirt",
});
const page = await client.getPage("beranda");
const nav = await client.getNavigation();
const announcements = await client.getAnnouncements();
const theme = await client.getTheme();| Method | Endpoint |
| --- | --- |
| getSite() | /api/v1/public/sites/{slug} |
| getPage(slug) | /api/v1/public/sites/{slug}/page/{pageSlug} |
| getHomepage() | resolves homepageSlug, then fetches that page |
| getNavigation() | /api/v1/public/sites/{slug}/navigation |
| getAnnouncements() | /api/v1/public/sites/{slug}/announcement |
| getTheme() | /api/v1/public/sites/{slug}/theme |
| mediaUrl(objectKey) | /api/v1/public/media/object/{objectKey} |
The public surface takes no auth, so the client sends no credentials and is safe to call from a browser or a server.
Caching. Responses carry ETag and Cache-Control. Pass framework hints
through requestInit:
const page = await client.getPage("beranda", { next: { revalidate: 60 } });Errors. Every non-2xx throws CmsApiError carrying the service's error
envelope. getPageOrNull() returns null on a 404 instead, for routes that
render their own not-found state.
Widget definitions
A WidgetDefinition is everything the CMS needs to describe, validate, default
and version a widget — with no components attached:
import { faqDefinition, builtInDefinitions } from "@csirt-cms/widget-core";
faqDefinition.key; // "faq"
faqDefinition.category; // "Content"
faqDefinition.version; // "1.0.0"
faqDefinition.schema; // ZodType<FaqProps>
faqDefinition.defaults; // FaqPropsTen widgets ship with the package: hero-carousel, feature-grid, faq,
statistics, gallery, announcement, contact, timeline, html,
markdown.
Props are hand-written interfaces
Each widget exports an explicit interface, with the schema pinned to it by
satisfies:
export interface FaqProps { /* … */ }
export const faqSchema = z.object({ /* … */ }) satisfies z.ZodType<FaqProps>;Not type FaqProps = z.infer<typeof faqSchema>, for two reasons. Vue's SFC
compiler resolves types itself, separately from TypeScript, to turn
defineProps<FaqProps>() into runtime prop declarations — it can follow a plain
interface across a package boundary but not Zod's inferred conditional types.
And named interfaces read far better than z.infer blobs in editor tooltips.
The satisfies clause is what stops the two drifting.
Registry
The registry is generic over the component type, which is what lets React and Vue share it:
import { createWidgetRegistry } from "@csirt-cms/widget-core";
const registry = createWidgetRegistry<MyComponentType>();
registry.register(widget);
registry.get("faq");
registry.all();
registry.byCategory("Content");
registry.categories(); // populated categories, canonical order
registry.search("carousel"); // name, key, description, category, tagsRegistering the same key at a different version throws rather than silently replacing it.
Validation and defaults
import { validateProps, withDefaults, safeProps } from "@csirt-cms/widget-core";
// Returns issues instead of throwing, so an editor can mark the bad fields.
const result = validateProps(faqDefinition, props);
if (!result.success) console.log(result.issues); // [{ path: "items.0.question", … }]
// Fill gaps from defaults.
const complete = withDefaults(faqDefinition, { title: "Help" });
// Never throws: unusable props fall back to defaults, so one malformed
// section degrades to its empty state instead of taking the page down.
const { props: safe, issues } = safeProps(faqDefinition, fromDatabase);Registering widgets with the CMS
The Admin API stores each component's props schema as JSON Schema, not Zod:
import { toComponentRegistration } from "@csirt-cms/widget-core";
await fetch(`${baseUrl}/api/v1/components`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(toComponentRegistration(faqDefinition)),
});This keeps the server-side component catalogue in step with the code.
Visibility
Mirrors SectionVisibilityDto — hidden, devices, startAt, endAt — plus
section status:
import { visibleSections } from "@csirt-cms/widget-core";
const renderable = visibleSections(sections, {
device: "mobile",
preview: false, // true also renders DRAFT and SCHEDULED, for the admin preview
now: new Date(),
});SCHEDULED sections are treated as publishable and left for the date checks to
include or exclude.
Migrations
Bump a widget's version and add a matching migration; stored props are walked
up to the current version at render time:
export const heroCarouselDefinition = {
version: "2.0.0",
migrations: [
{ from: "1.0.0", to: "2.0.0", migrate: (props) => ({ ...props, slides: props.items }) },
],
};Props with no recorded version are assumed current — a widget that has never had a breaking change needs no migrations, and guessing otherwise would corrupt good data.
Import / export
import {
exportSectionsToJson, importSections, duplicateSection, moveSection, toReorderPayload,
} from "@csirt-cms/widget-core";
const json = exportSectionsToJson(sections);
const imported = importSections(json); // DRAFT by default; nothing goes live by accident
const copy = duplicateSection(section);
const reordered = moveSection(sections, id, -1); // clamps at the edges
// Body for PUT /api/v1/pages/{pageId}/sections/reorder
await put(toReorderPayload(reordered));Theming
GET /api/v1/public/sites/{slug}/theme returns open token objects. Flatten them
into CSS custom properties:
import { themeToCssText, themeToCssVars, applyTheme } from "@csirt-cms/widget-core";
themeToCssVars({ colors: { primaryHover: "#111", brand: { 500: "#0af" } } });
// { "--cms-color-primary-hover": "#111", "--cms-color-brand-500": "#0af" }Inline themeToCssText(theme) in a <style> tag during the server render.
applyTheme() writes the same values after mount, which is simpler but flashes
the stylesheet defaults first.
HTML and Markdown
import { sanitizeHtml, renderMarkdown } from "@csirt-cms/widget-core";
sanitizeHtml('<p onclick="x()">hi</p><script>alert(1)</script>'); // "<p>hi</p>"
renderMarkdown("## Title\n\nSome **bold** text.");Both run identically in Node and the browser, so server-rendered and client-rendered markup match.
Choose the right sanitizer for your threat model.
sanitizeHtmlis an allowlist string tokenizer, not a spec-compliant HTML parser. It strips script and style elements and their contents, event-handler attributes,javascript:URLs, and everything outside the allowlist — the right trade-off for content authored by trusted CMS editors, which is what this package is built for. If widget props can carry input from untrusted or public sources, pass a battle-tested sanitizer instead via thesanitizerprop on thehtmlwidget:import DOMPurify from "isomorphic-dompurify"; <HtmlRenderer {...props} sanitizer={(html) => DOMPurify.sanitize(html)} />
renderMarkdown covers the subset CMS editors write: headings, emphasis, links,
images, lists, blockquotes, fenced and inline code, tables and horizontal rules.
Raw HTML inside Markdown is escaped to literal text. It is deliberately not a
CommonMark implementation — it ships zero dependencies.
License
MIT
