first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
{
"name": "@emdashcms/blocks",
"version": "0.0.0",
"description": "Declarative plugin UI blocks for EmDash CMS",
"type": "module",
"main": "dist/index.js",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"default": "./dist/server.js"
}
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"prepublishOnly": "node --run build",
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution",
"test": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@cloudflare/kumo": "^1.10.0",
"@phosphor-icons/react": "catalog:",
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@testing-library/react": "^16.3.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^4.6.0",
"jsdom": "^26.1.0",
"publint": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/cloudflare/emdash.git",
"directory": "packages/blocks"
},
"homepage": "https://github.com/cloudflare/emdash",
"keywords": [
"emdash",
"cms",
"blocks",
"plugin-ui",
"react"
],
"author": "Matt Kane",
"license": "MIT"
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EmDash Block Kit Playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,30 @@
{
"name": "@emdashcms/blocks-playground",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emdashcms/blocks": "workspace:*",
"@cloudflare/kumo": "^1.1.0",
"@phosphor-icons/react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.1.11",
"typescript": "catalog:",
"vite": "^6.3.5",
"wrangler": "^4.63.0"
},
"peerDependencies": {},
"optionalDependencies": {}
}

View File

@@ -0,0 +1,419 @@
import { Sun, Moon, Share, Check, Trash, CaretDown, Warning, Plus } from "@phosphor-icons/react";
import { BlockRenderer, validateBlocks } from "@emdashcms/blocks";
import type { Block, BlockInteraction } from "@emdashcms/blocks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { blockCatalog } from "./block-defaults";
import { templates } from "./templates";
import { useResizable } from "./useResizable";
// ── Types ────────────────────────────────────────────────────────────────────
interface ActionLogEntry {
id: number;
timestamp: Date;
interaction: BlockInteraction;
}
// ── Hash sharing ─────────────────────────────────────────────────────────────
function encodeToHash(blocks: Block[]): string {
try {
const json = JSON.stringify(blocks);
return btoa(encodeURIComponent(json));
} catch {
return "";
}
}
function decodeFromHash(hash: string): Block[] | null {
try {
const json = decodeURIComponent(atob(hash));
const parsed: unknown = JSON.parse(json);
if (!Array.isArray(parsed)) return null;
const result = validateBlocks(parsed);
if (!result.valid) return null;
return parsed as Block[];
} catch {
return null;
}
}
// ── Drag handle ──────────────────────────────────────────────────────────────
function DragHandle({
onMouseDown,
isDragging,
}: {
onMouseDown: (e: React.MouseEvent) => void;
isDragging: boolean;
}) {
return (
<div
className={`group relative w-[5px] shrink-0 cursor-col-resize ${isDragging ? "bg-kumo-info/40" : ""}`}
onMouseDown={onMouseDown}
>
{/* Visible border line */}
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-kumo-line group-hover:bg-kumo-info/50" />
</div>
);
}
// ── Component ────────────────────────────────────────────────────────────────
export function Playground() {
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
return "dark";
}
return "light";
});
// Load initial blocks from hash or first template
const [blocks, setBlocks] = useState<Block[]>(() => {
if (typeof window !== "undefined" && window.location.hash.length > 1) {
const decoded = decodeFromHash(window.location.hash.slice(1));
if (decoded) return decoded;
}
return templates[0]?.blocks ?? [];
});
const [editorText, setEditorText] = useState(() => JSON.stringify(blocks, null, 2));
const [parseError, setParseError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [actionLog, setActionLog] = useState<ActionLogEntry[]>([]);
const [copied, setCopied] = useState(false);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const nextId = useRef(0);
const templateMenuRef = useRef<HTMLDivElement>(null);
// Resizable panels
const catalog = useResizable({ initial: 220, min: 160, max: 320 });
const editor = useResizable({ initial: 480, min: 300, max: 800 });
// Apply theme
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
// Close template menu on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) {
setTemplateMenuOpen(false);
}
}
if (templateMenuOpen) {
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}
}, [templateMenuOpen]);
// Auto-scroll log
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [actionLog]);
// Parse editor text into blocks
const updateFromText = useCallback((text: string) => {
setEditorText(text);
try {
const parsed: unknown = JSON.parse(text);
setParseError(null);
if (!Array.isArray(parsed)) {
setParseError("Root must be an array of blocks");
return;
}
const result = validateBlocks(parsed);
const validated = parsed as Block[];
if (!result.valid) {
setValidationErrors(result.errors.map((e) => `${e.path}: ${e.message}`));
// Still render what we can
setBlocks(validated);
} else {
setValidationErrors([]);
setBlocks(validated);
}
} catch (err) {
setParseError(err instanceof Error ? err.message : "Invalid JSON");
}
}, []);
// Handle block interactions
const handleAction = useCallback((interaction: BlockInteraction) => {
setActionLog((prev) => [...prev, { id: nextId.current++, timestamp: new Date(), interaction }]);
}, []);
// Load a template
const loadTemplate = useCallback((index: number) => {
const template = templates[index];
if (!template) return;
const text = JSON.stringify(template.blocks, null, 2);
setEditorText(text);
setBlocks(template.blocks);
setParseError(null);
setValidationErrors([]);
setTemplateMenuOpen(false);
}, []);
// Insert a block from the catalog
const insertBlock = useCallback(
(catalogIndex: number) => {
const entry = blockCatalog[catalogIndex];
if (!entry) return;
const newBlock = entry.create();
const updated = [...blocks, newBlock];
const text = JSON.stringify(updated, null, 2);
setBlocks(updated);
setEditorText(text);
setParseError(null);
setValidationErrors([]);
},
[blocks],
);
// Share URL
const shareUrl = useCallback(async () => {
const hash = encodeToHash(blocks);
const url = `${window.location.origin}${window.location.pathname}#${hash}`;
window.history.replaceState(null, "", `#${hash}`);
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(setCopied, 2000, false);
} catch {
// Fallback: just update the URL
}
}, [blocks]);
// Error count for status bar
const errorCount = useMemo(() => {
let count = 0;
if (parseError) count++;
count += validationErrors.length;
return count;
}, [parseError, validationErrors]);
return (
<div className="flex h-screen flex-col" style={{ colorScheme: theme }}>
{/* ── Toolbar ─────────────────────────────────────── */}
<header className="flex h-11 shrink-0 items-center gap-2 border-b border-kumo-line bg-kumo-bg px-3">
<span className="text-sm font-semibold text-kumo-text">Block Kit Playground</span>
<div className="ml-auto flex items-center gap-1.5">
{/* Template picker */}
<div className="relative" ref={templateMenuRef}>
<button
type="button"
onClick={() => setTemplateMenuOpen((v) => !v)}
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium text-kumo-text-secondary hover:bg-kumo-tint"
>
Templates
<CaretDown size={12} weight="bold" />
</button>
{templateMenuOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-lg border border-kumo-line p-1 shadow-lg"
style={{ backgroundColor: "var(--kumo-bg, Canvas)" }}
>
{templates.map((t, i) => (
<button
key={t.name}
type="button"
onClick={() => loadTemplate(i)}
className="flex w-full flex-col items-start rounded-md px-3 py-2 text-left hover:bg-kumo-tint"
>
<span className="text-sm font-medium text-kumo-text">{t.name}</span>
<span className="text-xs text-kumo-text-secondary">{t.description}</span>
</button>
))}
</div>
)}
</div>
{/* Share */}
<button
type="button"
onClick={shareUrl}
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium text-kumo-text-secondary hover:bg-kumo-tint"
>
{copied ? <Check size={14} /> : <Share size={14} />}
{copied ? "Copied!" : "Share"}
</button>
{/* Theme toggle */}
<button
type="button"
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
className="rounded-md p-1.5 text-kumo-text-secondary hover:bg-kumo-tint"
aria-label="Toggle theme"
>
{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}
</button>
</div>
</header>
{/* ── Three-column layout ─────────────────────────── */}
<div className="flex min-h-0 flex-1">
{/* ── Left: Block catalog ──────────────────────── */}
<div className="flex shrink-0 flex-col" style={{ width: catalog.width }}>
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
Add Block
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto p-1.5">
{blockCatalog.map((entry, i) => (
<button
key={entry.type}
type="button"
onClick={() => insertBlock(i)}
className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left hover:bg-kumo-tint"
>
<Plus
size={14}
weight="bold"
className="mt-0.5 shrink-0 text-kumo-text-secondary"
/>
<div className="min-w-0">
<div className="text-xs font-medium text-kumo-text">{entry.label}</div>
<div className="text-[11px] leading-tight text-kumo-text-secondary">
{entry.description}
</div>
</div>
</button>
))}
</div>
</div>
<DragHandle onMouseDown={catalog.handleMouseDown} isDragging={catalog.isDragging} />
{/* ── Center: JSON editor ──────────────────────── */}
<div className="flex shrink-0 flex-col" style={{ width: editor.width }}>
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
JSON Editor
</span>
{errorCount > 0 && (
<span className="ml-auto flex items-center gap-1 text-[11px] font-medium text-kumo-warning">
<Warning size={12} weight="fill" />
{errorCount} {errorCount === 1 ? "error" : "errors"}
</span>
)}
</div>
<textarea
className="editor-textarea min-h-0 flex-1 border-none bg-kumo-bg p-3 text-kumo-text outline-none"
value={editorText}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateFromText(e.target.value)}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
/>
{/* Error display */}
{(parseError ?? validationErrors.length > 0) && (
<div className="max-h-32 shrink-0 overflow-auto border-t border-kumo-line bg-kumo-danger/5 p-2">
{parseError && <p className="text-xs text-kumo-danger">{parseError}</p>}
{validationErrors.map((err, i) => (
<p key={i} className="text-xs text-kumo-warning">
{err}
</p>
))}
</div>
)}
</div>
<DragHandle onMouseDown={editor.handleMouseDown} isDragging={editor.isDragging} />
{/* ── Right: Preview + Action log ──────────────── */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Preview */}
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
Preview
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
<div className="mx-auto max-w-2xl">
{!parseError && blocks.length > 0 ? (
<BlockRenderer blocks={blocks} onAction={handleAction} />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-kumo-text-secondary">
{parseError
? "Fix JSON errors to see preview"
: "Enter block JSON to see preview"}
</p>
</div>
)}
</div>
</div>
{/* Action log */}
<div className="flex h-48 shrink-0 flex-col border-t border-kumo-line">
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
Action Log
</span>
<span className="ml-1.5 rounded-full bg-kumo-tint px-1.5 py-0.5 text-[10px] font-medium text-kumo-text-secondary">
{actionLog.length}
</span>
{actionLog.length > 0 && (
<button
type="button"
onClick={() => setActionLog([])}
className="ml-auto rounded-md p-1 text-kumo-text-secondary hover:bg-kumo-tint"
aria-label="Clear log"
>
<Trash size={12} />
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{actionLog.length === 0 ? (
<p className="p-2 text-xs text-kumo-text-secondary">
Interact with the preview to see actions logged here.
</p>
) : (
actionLog.map((entry) => (
<div
key={entry.id}
className="action-log-entry border-b border-kumo-line/50 px-2 py-1.5 last:border-b-0"
>
<div className="flex items-baseline gap-2">
<span className="shrink-0 text-kumo-text-secondary">
{entry.timestamp.toLocaleTimeString()}
</span>
<span className="rounded bg-kumo-tint px-1 py-0.5 text-[10px] font-semibold uppercase text-kumo-text-secondary">
{entry.interaction.type}
</span>
{"action_id" in entry.interaction && (
<span className="font-medium text-kumo-text">
{entry.interaction.action_id}
</span>
)}
</div>
{"values" in entry.interaction && (
<pre className="mt-1 text-[11px] text-kumo-text-secondary">
{JSON.stringify(entry.interaction.values, null, 2)}
</pre>
)}
{"value" in entry.interaction && entry.interaction.value !== undefined && (
<pre className="mt-1 text-[11px] text-kumo-text-secondary">
{JSON.stringify(entry.interaction.value)}
</pre>
)}
</div>
))
)}
<div ref={logEndRef} />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,214 @@
import type { Block } from "@emdashcms/blocks";
interface BlockCatalogEntry {
type: Block["type"];
label: string;
description: string;
create: () => Block;
}
let counter = 0;
function nextId(prefix: string): string {
return `${prefix}_${++counter}`;
}
export const blockCatalog: BlockCatalogEntry[] = [
{
type: "header",
label: "Header",
description: "Page or section title",
create: () => ({
type: "header",
text: "New Header",
}),
},
{
type: "section",
label: "Section",
description: "Text paragraph with optional accessory",
create: () => ({
type: "section",
text: "Section text goes here. You can add an accessory element like a button.",
}),
},
{
type: "divider",
label: "Divider",
description: "Horizontal rule between blocks",
create: () => ({
type: "divider",
}),
},
{
type: "fields",
label: "Fields",
description: "Key-value pairs in a grid",
create: () => ({
type: "fields",
fields: [
{ label: "Label", value: "Value" },
{ label: "Another", value: "Value" },
],
}),
},
{
type: "stats",
label: "Stats",
description: "Metric cards with optional trends",
create: () => ({
type: "stats",
items: [
{ label: "Total", value: 100, trend: "up" as const },
{ label: "Active", value: 42 },
],
}),
},
{
type: "table",
label: "Table",
description: "Sortable data grid with pagination",
create: () => ({
type: "table",
columns: [
{ key: "name", label: "Name", sortable: true },
{ key: "status", label: "Status", format: "badge" as const },
],
rows: [
{ name: "Item 1", status: "active" },
{ name: "Item 2", status: "draft" },
],
page_action_id: nextId("page"),
}),
},
{
type: "form",
label: "Form",
description: "Input fields with submit button",
create: () => ({
type: "form",
fields: [
{
type: "text_input" as const,
action_id: nextId("field"),
label: "Text Field",
placeholder: "Enter text...",
},
],
submit: { label: "Submit", action_id: nextId("submit") },
}),
},
{
type: "actions",
label: "Actions",
description: "Row of buttons",
create: () => ({
type: "actions",
elements: [
{
type: "button" as const,
action_id: nextId("btn"),
label: "Click Me",
style: "primary" as const,
},
],
}),
},
{
type: "image",
label: "Image",
description: "Image with alt text and optional title",
create: () => ({
type: "image",
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=600&h=300&fit=crop",
alt: "Placeholder image",
title: "Image title",
}),
},
{
type: "context",
label: "Context",
description: "Muted supplementary text",
create: () => ({
type: "context",
text: "Supplementary information goes here.",
}),
},
{
type: "banner",
label: "Banner",
description: "Info, warning, or error message",
create: () => ({
type: "banner" as const,
title: "Notice",
description: "This is an informational banner message.",
variant: "default" as const,
}),
},
{
type: "columns",
label: "Columns",
description: "Multi-column layout",
create: () => ({
type: "columns",
columns: [
[{ type: "section" as const, text: "Left column content" }],
[{ type: "section" as const, text: "Right column content" }],
],
}),
},
{
type: "meter",
label: "Meter",
description: "Progress/quota meter bar",
create: () => ({
type: "meter" as const,
label: "Storage used",
value: 65,
custom_value: "6.5 GB / 10 GB",
}),
},
{
type: "code",
label: "Code",
description: "Syntax-highlighted code block",
create: () => ({
type: "code" as const,
code: 'const greeting = "Hello, World!";\nconsole.log(greeting);',
language: "ts" as const,
}),
},
{
type: "chart",
label: "Chart",
description: "Line, bar, or pie chart (ECharts)",
create: () => {
const now = Date.now();
const hour = 3_600_000;
return {
type: "chart" as const,
config: {
chart_type: "timeseries" as const,
series: [
{
name: "Requests",
data: Array.from({ length: 24 }, (_, i) => [
now - (23 - i) * hour,
Math.floor(200 + Math.random() * 300),
]),
},
{
name: "Errors",
data: Array.from({ length: 24 }, (_, i) => [
now - (23 - i) * hour,
Math.floor(Math.random() * 20),
]),
},
],
x_axis_name: "Time",
y_axis_name: "Count",
gradient: true,
},
};
},
},
];

View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
import { Playground } from "./Playground";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Playground />
</StrictMode>,
);

View File

@@ -0,0 +1,47 @@
@import "tailwindcss";
@import "@cloudflare/kumo/styles/tailwind";
/* Scan kumo components for utility classes */
@source "../../node_modules/@cloudflare/kumo/dist";
/* Scan blocks source for utility classes */
@source "../../src";
:root {
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background-color: light-dark(#f5f5f5, #111);
}
/* Editor textarea */
.editor-textarea {
font-family: "SF Mono", "Fira Code", "JetBrains Mono", ui-monospace, monospace;
font-size: 13px;
line-height: 1.5;
tab-size: 2;
resize: none;
}
/* Action log entries */
.action-log-entry {
font-family: "SF Mono", "Fira Code", "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
line-height: 1.4;
}

View File

@@ -0,0 +1,560 @@
import type { Block, ChartSeries } from "@emdashcms/blocks";
export interface Template {
name: string;
description: string;
blocks: Block[];
}
// ── Sample data generators ───────────────────────────────────────────────────
const HOUR = 3_600_000;
function generateTrafficSeries(): ChartSeries[] {
const now = Date.now();
return [
{
name: "Page Views",
data: Array.from({ length: 24 }, (_, i) => [
now - (23 - i) * HOUR,
Math.floor(400 + Math.sin(i / 4) * 200 + Math.random() * 80),
]),
color: "#086FFF",
},
{
name: "Unique Visitors",
data: Array.from({ length: 24 }, (_, i) => [
now - (23 - i) * HOUR,
Math.floor(150 + Math.sin(i / 4) * 80 + Math.random() * 40),
]),
color: "#CF7EE9",
},
];
}
function generateErrorSeries(): ChartSeries[] {
const now = Date.now();
return [
{
name: "4xx",
data: Array.from({ length: 12 }, (_, i) => [
now - (11 - i) * HOUR * 2,
Math.floor(Math.random() * 15),
]),
color: "#F8A054",
},
{
name: "5xx",
data: Array.from({ length: 12 }, (_, i) => [
now - (11 - i) * HOUR * 2,
Math.floor(Math.random() * 5),
]),
color: "#FC574A",
},
];
}
export const templates: Template[] = [
{
name: "Plugin Settings",
description: "Form with conditional fields and text inputs",
blocks: [
{
type: "header",
text: "SEO Plugin Settings",
block_id: "settings-header",
},
{
type: "section",
text: "Configure how your site appears in search results. Enable auto-generation to let the plugin create meta tags from your content.",
},
{ type: "divider" },
{
type: "form",
block_id: "seo-settings",
fields: [
{
type: "text_input",
action_id: "site_title",
label: "Site Title",
placeholder: "My Awesome Site",
initial_value: "EmDash CMS",
},
{
type: "text_input",
action_id: "meta_description",
label: "Default Meta Description",
placeholder: "A brief description of your site...",
multiline: true,
},
{
type: "toggle",
action_id: "auto_generate",
label: "Auto-generate meta tags",
description: "Generate meta descriptions from content when not manually set",
initial_value: true,
},
{
type: "number_input",
action_id: "max_length",
label: "Max description length",
initial_value: 160,
min: 50,
max: 300,
condition: { field: "auto_generate", eq: true },
},
{
type: "select",
action_id: "ai_model",
label: "AI Model",
options: [
{ label: "GPT-4o Mini", value: "gpt-4o-mini" },
{ label: "Claude Haiku", value: "claude-haiku" },
{ label: "None (extractive)", value: "none" },
],
initial_value: "none",
condition: { field: "auto_generate", eq: true },
},
{
type: "radio",
action_id: "default_status",
label: "Default publish status",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
{ label: "Scheduled", value: "scheduled" },
],
initial_value: "draft",
},
{
type: "checkbox",
action_id: "collections",
label: "Apply to collections",
options: [
{ label: "Posts", value: "posts" },
{ label: "Pages", value: "pages" },
{ label: "Products", value: "products" },
],
initial_value: ["posts", "pages"],
},
{
type: "secret_input",
action_id: "api_key",
label: "API Key",
placeholder: "sk-...",
condition: { field: "ai_model", neq: "none" },
},
{
type: "date_input",
action_id: "embargo_date",
label: "Embargo date",
placeholder: "Select a date",
},
{
type: "combobox",
action_id: "timezone",
label: "Timezone",
placeholder: "Search timezones...",
options: [
{ label: "UTC", value: "UTC" },
{ label: "US/Eastern", value: "US/Eastern" },
{ label: "US/Central", value: "US/Central" },
{ label: "US/Mountain", value: "US/Mountain" },
{ label: "US/Pacific", value: "US/Pacific" },
{ label: "Europe/London", value: "Europe/London" },
{ label: "Europe/Paris", value: "Europe/Paris" },
{ label: "Europe/Berlin", value: "Europe/Berlin" },
{ label: "Asia/Tokyo", value: "Asia/Tokyo" },
{ label: "Asia/Shanghai", value: "Asia/Shanghai" },
{ label: "Australia/Sydney", value: "Australia/Sydney" },
],
initial_value: "UTC",
},
],
submit: { label: "Save Settings", action_id: "save_seo_settings" },
},
],
},
{
name: "Analytics Dashboard",
description: "Charts, stats, and data table",
blocks: [
{
type: "header",
text: "Content Analytics",
},
{
type: "stats",
items: [
{ label: "Total Views", value: "12,847", trend: "up", description: "+14% vs last week" },
{
label: "Unique Visitors",
value: "3,291",
trend: "up",
description: "+8% vs last week",
},
{
label: "Bounce Rate",
value: "34.2%",
trend: "down",
description: "-2.1% vs last week",
},
{ label: "Avg. Time on Page", value: "2m 48s", trend: "neutral" },
],
},
{
type: "chart",
block_id: "traffic-chart",
config: {
chart_type: "timeseries",
series: generateTrafficSeries(),
x_axis_name: "Time",
y_axis_name: "Requests",
gradient: true,
},
},
{
type: "columns",
columns: [
[
{
type: "chart",
block_id: "content-breakdown",
config: {
chart_type: "custom",
options: {
series: [
{
type: "pie",
radius: ["40%", "70%"],
data: [
{ value: 42, name: "Published" },
{ value: 7, name: "Draft" },
{ value: 3, name: "Scheduled" },
{ value: 2, name: "Archived" },
],
},
],
},
height: 250,
},
},
],
[
{
type: "chart",
block_id: "errors-chart",
config: {
chart_type: "timeseries",
series: generateErrorSeries(),
y_axis_name: "Errors",
style: "bar",
height: 250,
},
},
],
],
},
{ type: "divider" },
{
type: "table",
block_id: "content-table",
columns: [
{ key: "title", label: "Title", sortable: true },
{ key: "status", label: "Status", format: "badge" },
{ key: "views", label: "Views", format: "number", sortable: true },
{ key: "updated", label: "Last Updated", format: "relative_time", sortable: true },
],
rows: [
{
title: "Getting Started with EmDash",
status: "published",
views: 4521,
updated: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
{
title: "Advanced Content Modeling",
status: "published",
views: 2103,
updated: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
},
{
title: "Plugin Development Guide",
status: "draft",
views: 891,
updated: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
},
{
title: "Deployment to Cloudflare",
status: "published",
views: 3187,
updated: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
},
{
title: "Media Management",
status: "scheduled",
views: 0,
updated: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
},
],
page_action_id: "load_more_content",
next_cursor: "eyJpZCI6IjUifQ",
},
],
},
{
name: "Dashboard Widget",
description: "Compact stats with context line",
blocks: [
{
type: "stats",
items: [
{ label: "Published", value: 42 },
{ label: "Drafts", value: 7 },
{ label: "Scheduled", value: 3 },
],
},
{
type: "context",
text: "Last published 2 hours ago \u2022 Next scheduled in 4 hours",
},
],
},
{
name: "Admin Page",
description: "Two-column layout with sidebar and main content",
blocks: [
{
type: "header",
text: "Site Configuration",
},
{
type: "columns",
columns: [
[
{
type: "section",
text: "General settings for your site. These values are used across all pages unless overridden at the content level.",
},
{
type: "form",
block_id: "general-settings",
fields: [
{
type: "text_input",
action_id: "site_name",
label: "Site Name",
initial_value: "My Site",
},
{
type: "text_input",
action_id: "tagline",
label: "Tagline",
placeholder: "A short description of your site",
},
{
type: "select",
action_id: "timezone",
label: "Timezone",
options: [
{ label: "UTC", value: "UTC" },
{ label: "US/Eastern", value: "US/Eastern" },
{ label: "US/Pacific", value: "US/Pacific" },
{ label: "Europe/London", value: "Europe/London" },
],
initial_value: "UTC",
},
],
submit: { label: "Save", action_id: "save_general" },
},
],
[
{
type: "fields",
fields: [
{ label: "Plan", value: "Pro" },
{ label: "Storage", value: "2.4 GB / 10 GB" },
{ label: "API Calls", value: "12,847 / 100,000" },
{ label: "Next Billing", value: "Mar 15, 2026" },
],
},
{ type: "divider" },
{
type: "actions",
elements: [
{
type: "button",
action_id: "export_data",
label: "Export Data",
style: "secondary",
},
{
type: "button",
action_id: "danger_zone",
label: "Delete Site",
style: "danger",
confirm: {
title: "Delete Site?",
text: "This action cannot be undone. All content and media will be permanently deleted.",
confirm: "Delete Everything",
deny: "Cancel",
style: "danger",
},
},
],
},
],
],
},
],
},
{
name: "All Blocks",
description: "Showcase of every block type",
blocks: [
{ type: "header", text: "Block Kit Showcase" },
{
type: "section",
text: "This template demonstrates every block type available in the Block Kit. Each block maps to a Kumo component.",
accessory: {
type: "button",
action_id: "learn_more",
label: "Learn More",
style: "primary",
},
},
{
type: "banner",
title: "Information",
description: "This is a default informational banner.",
variant: "default",
},
{
type: "banner",
title: "Warning",
description: "Something requires your attention.",
variant: "alert",
},
{
type: "banner",
title: "Error",
description: "An error occurred while processing your request.",
variant: "error",
},
{ type: "divider" },
{
type: "fields",
fields: [
{ label: "Version", value: "0.1.0" },
{ label: "Blocks", value: "15 types" },
{ label: "Elements", value: "10 types" },
{ label: "License", value: "MIT" },
],
},
{
type: "stats",
items: [
{ label: "Components", value: 26, trend: "up" },
{ label: "Tests", value: 60, trend: "up" },
{ label: "Bundle Size", value: "6.7 KB", description: "gzipped" },
],
},
{
type: "meter",
label: "Storage",
value: 65,
custom_value: "6.5 GB / 10 GB",
},
{ type: "divider" },
{
type: "chart",
block_id: "demo-timeseries",
config: {
chart_type: "timeseries",
series: generateTrafficSeries(),
x_axis_name: "Time",
y_axis_name: "Views",
gradient: true,
},
},
{
type: "chart",
block_id: "demo-pie",
config: {
chart_type: "custom",
options: {
series: [
{
type: "pie",
radius: ["40%", "70%"],
data: [
{ value: 335, name: "Published" },
{ value: 234, name: "Draft" },
{ value: 120, name: "Scheduled" },
{ value: 48, name: "Archived" },
],
},
],
},
height: 280,
},
},
{ type: "divider" },
{
type: "image",
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800&h=400&fit=crop",
alt: "Abstract colorful gradient",
title: "Image blocks support URLs, alt text, and optional titles",
},
{
type: "code",
code: 'import { blocks } from "@emdashcms/blocks";\n\nconst page = [\n\tblocks.header("Hello"),\n\tblocks.section("Welcome to EmDash."),\n];',
language: "ts",
},
{ type: "divider" },
{
type: "table",
block_id: "demo-table",
columns: [
{ key: "block", label: "Block Type" },
{ key: "purpose", label: "Purpose" },
{ key: "status", label: "Status", format: "badge" },
],
rows: [
{ block: "header", purpose: "Page or section title", status: "stable" },
{ block: "section", purpose: "Text with optional accessory", status: "stable" },
{ block: "form", purpose: "Input fields with submit", status: "stable" },
{ block: "table", purpose: "Sortable data grid", status: "stable" },
{ block: "chart", purpose: "Timeseries, bar, pie charts", status: "stable" },
{ block: "columns", purpose: "Multi-column layout", status: "stable" },
],
page_action_id: "demo_page",
},
{ type: "divider" },
{
type: "actions",
elements: [
{ type: "button", action_id: "primary_action", label: "Primary", style: "primary" },
{ type: "button", action_id: "secondary_action", label: "Secondary", style: "secondary" },
{
type: "button",
action_id: "danger_action",
label: "Danger",
style: "danger",
confirm: {
title: "Are you sure?",
text: "This is a destructive action demo.",
confirm: "Yes, proceed",
deny: "Cancel",
style: "danger",
},
},
],
},
{
type: "context",
text: "This is a context block \u2014 used for supplementary information, timestamps, or footnotes.",
},
],
},
];

View File

@@ -0,0 +1,54 @@
import { useCallback, useRef, useState } from "react";
interface UseResizableOptions {
/** Initial width in pixels */
initial: number;
/** Minimum width */
min: number;
/** Maximum width */
max: number;
}
interface UseResizableReturn {
width: number;
isDragging: boolean;
handleMouseDown: (e: React.MouseEvent) => void;
}
export function useResizable({ initial, min, max }: UseResizableOptions): UseResizableReturn {
const [width, setWidth] = useState(initial);
const [isDragging, setIsDragging] = useState(false);
const startX = useRef(0);
const startWidth = useRef(0);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
startX.current = e.clientX;
startWidth.current = width;
setIsDragging(true);
function onMouseMove(moveEvent: MouseEvent) {
const delta = moveEvent.clientX - startX.current;
const newWidth = Math.min(max, Math.max(min, startWidth.current + delta));
setWidth(newWidth);
}
function onMouseUp() {
setIsDragging(false);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
[width, min, max],
);
return { width, isDragging, handleMouseDown };
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,13 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
// Resolve @emdashcms/blocks from source for HMR
"@emdashcms/blocks": new URL("../src/index.ts", import.meta.url).pathname,
},
},
});

View File

@@ -0,0 +1,7 @@
{
"name": "emdash-blocks",
"compatibility_date": "2026-02-25",
"assets": {
"directory": "./dist",
},
}

View File

@@ -0,0 +1,18 @@
import { renderElement } from "../render-element.js";
import type { ActionsBlock, BlockInteraction } from "../types.js";
export function ActionsBlockComponent({
block,
onAction,
}: {
block: ActionsBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
return (
<div className="flex flex-wrap gap-2">
{block.elements.map((el, i) => (
<div key={el.action_id ?? i}>{renderElement(el, onAction)}</div>
))}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Banner } from "@cloudflare/kumo";
import { Info, Warning, WarningCircle } from "@phosphor-icons/react";
import { useMemo } from "react";
import type { BannerBlock } from "../types.js";
function useVariantIcon(variant: "default" | "alert" | "error") {
return useMemo(() => {
switch (variant) {
case "alert":
return <Warning weight="fill" size={20} />;
case "error":
return <WarningCircle weight="fill" size={20} />;
default:
return <Info weight="fill" size={20} />;
}
}, [variant]);
}
export function BannerBlockComponent({ block }: { block: BannerBlock }) {
const variant = block.variant ?? "default";
const icon = useVariantIcon(variant);
return (
<Banner variant={variant} icon={icon} title={block.title} description={block.description} />
);
}

View File

@@ -0,0 +1,159 @@
import { Chart, ChartPalette, TimeseriesChart } from "@cloudflare/kumo/components/chart";
import type { EChartsOption } from "echarts";
import { BarChart, LineChart, PieChart } from "echarts/charts";
import {
AriaComponent,
AxisPointerComponent,
GridComponent,
TooltipComponent,
} from "echarts/components";
import * as echarts from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { useMemo } from "react";
import type { ChartBlock } from "../types.js";
import { useIsDarkMode } from "../utils.js";
echarts.use([
BarChart,
LineChart,
PieChart,
AriaComponent,
AxisPointerComponent,
GridComponent,
TooltipComponent,
CanvasRenderer,
]);
// ── Security: HTML-escape untrusted strings before they reach ECharts ────────
// ECharts tooltip renders via innerHTML. Plugin-supplied names/labels must be
// escaped to prevent stored XSS in the admin dashboard.
const RE_AMP = /&/g;
const RE_LT = /</g;
const RE_GT = />/g;
const RE_QUOT = /"/g;
const RE_APOS = /'/g;
function escapeHtml(str: string): string {
return str
.replace(RE_AMP, "&amp;")
.replace(RE_LT, "&lt;")
.replace(RE_GT, "&gt;")
.replace(RE_QUOT, "&quot;")
.replace(RE_APOS, "&#039;");
}
// ── Security: Sanitize custom ECharts options ────────────────────────────────
// Plugin-supplied options are passed to chart.setOption(). ECharts accepts
// formatter strings rendered via innerHTML, tooltip HTML, and graphic elements
// that can execute arbitrary code. We strip dangerous properties and force
// richText tooltip mode to eliminate HTML injection vectors.
/** Keys that accept HTML strings or executable content in ECharts options */
const DANGEROUS_KEYS = new Set(["formatter", "rich", "graphic", "axisPointer"]);
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
const RE_HTML_TAG = /<[a-z/!]/i;
function containsHtml(v: unknown): boolean {
return typeof v === "string" && RE_HTML_TAG.test(v);
}
/**
* Deep-clone an ECharts options object, stripping properties that could
* inject HTML or executable content. Strings containing HTML tags are
* replaced with escaped versions.
*/
function sanitizeOptions(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (DANGEROUS_KEYS.has(key)) continue;
if (containsHtml(value)) {
result[key] = escapeHtml(value as string);
} else if (Array.isArray(value)) {
result[key] = value.map((item) =>
isRecord(item)
? sanitizeOptions(item)
: containsHtml(item)
? escapeHtml(item as string)
: item,
);
} else if (isRecord(value)) {
result[key] = sanitizeOptions(value);
} else {
result[key] = value;
}
}
return result;
}
function TimeseriesChartBlock({ block, isDarkMode }: { block: ChartBlock; isDarkMode: boolean }) {
const config = block.config;
if (config.chart_type !== "timeseries") return null;
const data = useMemo(
() =>
config.series.map((s, i) => ({
name: escapeHtml(s.name),
data: s.data,
color: s.color ?? ChartPalette.color(i, isDarkMode),
})),
[config.series, isDarkMode],
);
return (
<TimeseriesChart
echarts={echarts}
isDarkMode={isDarkMode}
type={config.style}
data={data}
xAxisName={config.x_axis_name ? escapeHtml(config.x_axis_name) : undefined}
yAxisName={config.y_axis_name ? escapeHtml(config.y_axis_name) : undefined}
height={config.height}
gradient={config.gradient}
/>
);
}
function CustomChartBlock({ block, isDarkMode }: { block: ChartBlock; isDarkMode: boolean }) {
const config = block.config;
if (config.chart_type !== "custom") return null;
const safeOptions = useMemo(() => {
const sanitized = sanitizeOptions(config.options);
// Force richText tooltip mode — renders via canvas, not innerHTML
if (isRecord(sanitized.tooltip)) {
sanitized.tooltip.renderMode = "richText";
} else {
sanitized.tooltip = { renderMode: "richText" };
}
return sanitized;
}, [config.options]);
return (
<Chart
echarts={echarts}
isDarkMode={isDarkMode}
options={safeOptions as EChartsOption}
height={config.height}
/>
);
}
export function ChartBlockComponent({ block }: { block: ChartBlock }) {
const isDarkMode = useIsDarkMode();
return (
<div className="rounded-lg border border-kumo-line p-4">
{block.config.chart_type === "timeseries" ? (
<TimeseriesChartBlock block={block} isDarkMode={isDarkMode} />
) : (
<CustomChartBlock block={block} isDarkMode={isDarkMode} />
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { CodeBlock as KumoCodeBlock } from "@cloudflare/kumo";
import type { CodeBlock } from "../types.js";
export function CodeBlockComponent({ block }: { block: CodeBlock }) {
return <KumoCodeBlock code={block.code} lang={block.language} />;
}

View File

@@ -0,0 +1,23 @@
import { BlockRenderer } from "../renderer.js";
import type { BlockInteraction, ColumnsBlock } from "../types.js";
export function ColumnsBlockComponent({
block,
onAction,
}: {
block: ColumnsBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
const colCount = Math.min(block.columns.length, 3);
const gridClass = colCount === 2 ? "grid grid-cols-2 gap-4" : "grid grid-cols-3 gap-4";
return (
<div className={gridClass}>
{block.columns.map((col, i) => (
<div key={i}>
<BlockRenderer blocks={col} onAction={onAction} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import type { ContextBlock } from "../types.js";
export function ContextBlockComponent({ block }: { block: ContextBlock }) {
return <p className="text-sm text-kumo-subtle">{block.text}</p>;
}

View File

@@ -0,0 +1,3 @@
export function DividerBlockComponent() {
return <hr className="my-4 border-kumo-line" />;
}

View File

@@ -0,0 +1,14 @@
import type { FieldsBlock } from "../types.js";
export function FieldsBlockComponent({ block }: { block: FieldsBlock }) {
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
{block.fields.map((field, i) => (
<div key={i}>
<div className="text-sm text-kumo-subtle">{field.label}</div>
<div className="text-kumo-default">{field.value}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Button } from "@cloudflare/kumo";
import { useCallback, useState } from "react";
import { renderElement } from "../render-element.js";
import type { BlockInteraction, FieldCondition, FormBlock, FormField } from "../types.js";
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, i) => deepEqual(v, b[i]));
}
return false;
}
function evaluateCondition(condition: FieldCondition, values: Record<string, unknown>): boolean {
const fieldValue = values[condition.field];
if ("eq" in condition && condition.eq !== undefined) {
return deepEqual(fieldValue, condition.eq);
}
if ("neq" in condition && condition.neq !== undefined) {
return !deepEqual(fieldValue, condition.neq);
}
return true;
}
function getInitialValues(fields: FormField[]): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const field of fields) {
if ("initial_value" in field && field.initial_value !== undefined) {
values[field.action_id] = field.initial_value;
}
}
return values;
}
export function FormBlockComponent({
block,
onAction,
}: {
block: FormBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
const [values, setValues] = useState<Record<string, unknown>>(() =>
getInitialValues(block.fields),
);
const handleChange = useCallback((actionId: string, value: unknown) => {
setValues((prev) => ({ ...prev, [actionId]: value }));
}, []);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onAction({
type: "form_submit",
action_id: block.submit.action_id,
block_id: block.block_id,
values,
});
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{block.fields.map((field) => {
if (field.condition && !evaluateCondition(field.condition, values)) {
return null;
}
return <div key={field.action_id}>{renderElement(field, onAction, handleChange)}</div>;
})}
<div>
<Button type="submit">{block.submit.label}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,5 @@
import type { HeaderBlock } from "../types.js";
export function HeaderBlockComponent({ block }: { block: HeaderBlock }) {
return <h2 className="text-xl font-bold text-kumo-default">{block.text}</h2>;
}

View File

@@ -0,0 +1,12 @@
import type { ImageBlock } from "../types.js";
export function ImageBlockComponent({ block }: { block: ImageBlock }) {
return (
<figure>
<img src={block.url} alt={block.alt} className="max-w-full rounded" />
{block.title && (
<figcaption className="mt-1 text-sm text-kumo-subtle">{block.title}</figcaption>
)}
</figure>
);
}

View File

@@ -0,0 +1,15 @@
import { Meter } from "@cloudflare/kumo";
import type { MeterBlock } from "../types.js";
export function MeterBlockComponent({ block }: { block: MeterBlock }) {
return (
<Meter
label={block.label}
value={block.value}
max={block.max}
min={block.min}
customValue={block.custom_value}
/>
);
}

View File

@@ -0,0 +1,19 @@
import { renderElement } from "../render-element.js";
import type { BlockInteraction, SectionBlock } from "../types.js";
export function SectionBlockComponent({
block,
onAction,
}: {
block: SectionBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
return (
<div className="flex items-start justify-between gap-4">
<div className="flex-1 text-kumo-default">{block.text}</div>
{block.accessory && (
<div className="flex-shrink-0">{renderElement(block.accessory, onAction)}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { ArrowDown, ArrowUp, Minus } from "@phosphor-icons/react";
import type { StatItem, StatsBlock } from "../types.js";
import { cn } from "../utils.js";
const trendConfig = {
up: { icon: ArrowUp, color: "text-green-600" },
down: { icon: ArrowDown, color: "text-red-600" },
neutral: { icon: Minus, color: "text-kumo-subtle" },
} as const;
function StatCard({ item }: { item: StatItem }) {
const trend = item.trend ? trendConfig[item.trend] : null;
const TrendIcon = trend?.icon;
return (
<div className="flex-1 rounded-lg border border-kumo-line p-4">
<div className="text-sm text-kumo-subtle">{item.label}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-bold text-kumo-default">{item.value}</span>
{TrendIcon && (
<span className={cn("flex items-center", trend.color)}>
<TrendIcon size={16} />
</span>
)}
</div>
{item.description && <div className="mt-1 text-sm text-kumo-subtle">{item.description}</div>}
</div>
);
}
export function StatsBlockComponent({ block }: { block: StatsBlock }) {
return (
<div className="flex gap-4">
{block.items.map((item, i) => (
<StatCard key={i} item={item} />
))}
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { Badge } from "@cloudflare/kumo";
import { ArrowDown, ArrowUp } from "@phosphor-icons/react";
import { useState } from "react";
import type { BlockInteraction, TableBlock, TableColumn } from "../types.js";
import { cn, formatRelativeTime } from "../utils.js";
function formatCell(value: unknown, format: TableColumn["format"]): React.ReactNode {
let str: string;
if (value == null) {
str = "";
} else if (typeof value === "string") {
str = value;
} else if (typeof value === "number" || typeof value === "boolean") {
str = String(value);
} else if (typeof value === "object") {
str = JSON.stringify(value);
} else {
str = "";
}
switch (format) {
case "badge":
return <Badge>{str}</Badge>;
case "relative_time":
return str ? formatRelativeTime(str) : "";
case "number": {
const num = Number(value);
return Number.isNaN(num) ? str : num.toLocaleString();
}
case "code":
return <code className="rounded bg-kumo-tint px-1.5 py-0.5 font-mono text-sm">{str}</code>;
default:
return str;
}
}
export function TableBlockComponent({
block,
onAction,
}: {
block: TableBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
function handleSort(key: string) {
const next =
sort?.key === key && sort.dir === "asc"
? { key, dir: "desc" as const }
: { key, dir: "asc" as const };
setSort(next);
onAction({
type: "block_action",
action_id: block.page_action_id,
block_id: block.block_id,
value: { sort: next },
});
}
function handleLoadMore() {
onAction({
type: "block_action",
action_id: block.page_action_id,
block_id: block.block_id,
value: { cursor: block.next_cursor, sort },
});
}
if (block.rows.length === 0 && block.empty_text) {
return <p className="py-4 text-center text-sm text-kumo-subtle">{block.empty_text}</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-kumo-line">
{block.columns.map((col) => (
<th
key={col.key}
className={cn(
"px-3 py-2 text-sm font-medium text-kumo-subtle",
col.sortable && "cursor-pointer select-none",
)}
onClick={col.sortable ? () => handleSort(col.key) : undefined}
>
<span className="inline-flex items-center gap-1">
{col.label}
{col.sortable &&
sort?.key === col.key &&
(sort.dir === "asc" ? <ArrowUp size={14} /> : <ArrowDown size={14} />)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i} className="border-b border-kumo-line last:border-0">
{block.columns.map((col) => (
<td key={col.key} className="px-3 py-2 text-kumo-default">
{formatCell(row[col.key], col.format)}
</td>
))}
</tr>
))}
</tbody>
</table>
{block.next_cursor && (
<div className="mt-2 flex justify-center">
<button
type="button"
onClick={handleLoadMore}
className="text-sm text-kumo-link hover:underline"
>
Load more
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,432 @@
import type {
ActionsBlock,
BannerBlock,
Block,
ButtonElement,
CheckboxElement,
ChartBlock,
ChartSeries,
CodeBlock,
ComboboxElement,
ColumnsBlock,
ConfirmDialog,
ContextBlock,
DateInputElement,
RadioElement,
DividerBlock,
Element,
FieldsBlock,
FormBlock,
FormField,
HeaderBlock,
ImageBlock,
MeterBlock,
NumberInputElement,
SecretInputElement,
SectionBlock,
SelectElement,
StatItem,
StatsBlock,
TableBlock,
TableColumn,
TextInputElement,
ToggleElement,
} from "./types.js";
// ── Block Builders ───────────────────────────────────────────────────────────
function header(text: string, opts?: { blockId?: string }): HeaderBlock {
return {
type: "header",
text,
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function section(text: string, opts?: { accessory?: Element; blockId?: string }): SectionBlock {
return {
type: "section",
text,
...(opts?.accessory !== undefined && { accessory: opts.accessory }),
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function divider(opts?: { blockId?: string }): DividerBlock {
return {
type: "divider",
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function fieldsBlock(
fields: Array<{ label: string; value: string }>,
opts?: { blockId?: string },
): FieldsBlock {
return {
type: "fields",
fields,
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function table(opts: {
blockId?: string;
columns: TableColumn[];
rows: Array<Record<string, unknown>>;
nextCursor?: string;
pageActionId: string;
emptyText?: string;
}): TableBlock {
return {
type: "table",
columns: opts.columns,
rows: opts.rows,
page_action_id: opts.pageActionId,
...(opts.nextCursor !== undefined && { next_cursor: opts.nextCursor }),
...(opts.emptyText !== undefined && { empty_text: opts.emptyText }),
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
function actionsBlock(elements: Element[], opts?: { blockId?: string }): ActionsBlock {
return {
type: "actions",
elements,
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function stats(items: StatItem[], opts?: { blockId?: string }): StatsBlock {
return {
type: "stats",
items,
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function form(opts: {
blockId?: string;
fields: FormField[];
submit: { label: string; actionId: string };
}): FormBlock {
return {
type: "form",
fields: opts.fields,
submit: { label: opts.submit.label, action_id: opts.submit.actionId },
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
function image(opts: { url: string; alt: string; title?: string; blockId?: string }): ImageBlock {
return {
type: "image",
url: opts.url,
alt: opts.alt,
...(opts.title !== undefined && { title: opts.title }),
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
function context(text: string, opts?: { blockId?: string }): ContextBlock {
return {
type: "context",
text,
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function columnsBlock(columns: Block[][], opts?: { blockId?: string }): ColumnsBlock {
return {
type: "columns",
columns,
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function bannerBlock(
opts: {
blockId?: string;
variant?: "default" | "alert" | "error";
} & ({ title: string; description?: string } | { title?: string; description: string }),
): BannerBlock {
return {
type: "banner",
...(opts.title !== undefined && { title: opts.title }),
...(opts.description !== undefined && { description: opts.description }),
...(opts.variant !== undefined && { variant: opts.variant }),
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
// ── Element Builders ─────────────────────────────────────────────────────────
function textInput(
actionId: string,
label: string,
opts?: {
placeholder?: string;
initialValue?: string;
multiline?: boolean;
},
): TextInputElement {
return {
type: "text_input",
action_id: actionId,
label,
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
...(opts?.initialValue !== undefined && {
initial_value: opts.initialValue,
}),
...(opts?.multiline !== undefined && { multiline: opts.multiline }),
};
}
function numberInput(
actionId: string,
label: string,
opts?: { initialValue?: number; min?: number; max?: number },
): NumberInputElement {
return {
type: "number_input",
action_id: actionId,
label,
...(opts?.initialValue !== undefined && {
initial_value: opts.initialValue,
}),
...(opts?.min !== undefined && { min: opts.min }),
...(opts?.max !== undefined && { max: opts.max }),
};
}
function select(
actionId: string,
label: string,
options: Array<{ label: string; value: string }>,
opts?: { initialValue?: string },
): SelectElement {
return {
type: "select",
action_id: actionId,
label,
options,
...(opts?.initialValue !== undefined && {
initial_value: opts.initialValue,
}),
};
}
function toggle(
actionId: string,
label: string,
opts?: { description?: string; initialValue?: boolean },
): ToggleElement {
return {
type: "toggle",
action_id: actionId,
label,
...(opts?.description !== undefined && { description: opts.description }),
...(opts?.initialValue !== undefined && {
initial_value: opts.initialValue,
}),
};
}
function button(
actionId: string,
label: string,
opts?: {
style?: "primary" | "danger" | "secondary";
value?: unknown;
confirm?: ConfirmDialog;
},
): ButtonElement {
return {
type: "button",
action_id: actionId,
label,
...(opts?.style !== undefined && { style: opts.style }),
...(opts?.value !== undefined && { value: opts.value }),
...(opts?.confirm !== undefined && { confirm: opts.confirm }),
};
}
function secretInput(
actionId: string,
label: string,
opts?: { placeholder?: string; hasValue?: boolean },
): SecretInputElement {
return {
type: "secret_input",
action_id: actionId,
label,
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
...(opts?.hasValue !== undefined && { has_value: opts.hasValue }),
};
}
function checkbox(
actionId: string,
label: string,
options: Array<{ label: string; value: string }>,
opts?: { initialValue?: string[] },
): CheckboxElement {
return {
type: "checkbox",
action_id: actionId,
label,
options,
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
};
}
function dateInput(
actionId: string,
label: string,
opts?: { initialValue?: string; placeholder?: string },
): DateInputElement {
return {
type: "date_input",
action_id: actionId,
label,
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
};
}
function combobox(
actionId: string,
label: string,
options: Array<{ label: string; value: string }>,
opts?: { initialValue?: string; placeholder?: string },
): ComboboxElement {
return {
type: "combobox",
action_id: actionId,
label,
options,
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
};
}
function radio(
actionId: string,
label: string,
options: Array<{ label: string; value: string }>,
opts?: { initialValue?: string },
): RadioElement {
return {
type: "radio",
action_id: actionId,
label,
options,
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
};
}
function timeseriesChart(opts: {
blockId?: string;
series: ChartSeries[];
style?: "line" | "bar";
xAxisName?: string;
yAxisName?: string;
height?: number;
gradient?: boolean;
}): ChartBlock {
return {
type: "chart",
config: {
chart_type: "timeseries",
series: opts.series,
...(opts.style !== undefined && { style: opts.style }),
...(opts.xAxisName !== undefined && { x_axis_name: opts.xAxisName }),
...(opts.yAxisName !== undefined && { y_axis_name: opts.yAxisName }),
...(opts.height !== undefined && { height: opts.height }),
...(opts.gradient !== undefined && { gradient: opts.gradient }),
},
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
function customChart(opts: {
blockId?: string;
options: Record<string, unknown>;
height?: number;
}): ChartBlock {
return {
type: "chart",
config: {
chart_type: "custom",
options: opts.options,
...(opts.height !== undefined && { height: opts.height }),
},
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
function meter(opts: {
blockId?: string;
label: string;
value: number;
max?: number;
min?: number;
customValue?: string;
}): MeterBlock {
return {
type: "meter",
label: opts.label,
value: opts.value,
...(opts.max !== undefined && { max: opts.max }),
...(opts.min !== undefined && { min: opts.min }),
...(opts.customValue !== undefined && { custom_value: opts.customValue }),
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
function codeBlock(opts: {
blockId?: string;
code: string;
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
}): CodeBlock {
return {
type: "code",
code: opts.code,
...(opts.language !== undefined && { language: opts.language }),
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
// ── Exports ──────────────────────────────────────────────────────────────────
export const blocks = {
header,
section,
divider,
fields: fieldsBlock,
table,
actions: actionsBlock,
stats,
form,
image,
context,
columns: columnsBlock,
timeseriesChart,
customChart,
banner: bannerBlock,
meter,
code: codeBlock,
};
export const elements = {
textInput,
numberInput,
select,
toggle,
button,
secretInput,
checkbox,
combobox,
dateInput,
radio,
};

View File

@@ -0,0 +1,69 @@
import { Button, Dialog, DialogRoot } from "@cloudflare/kumo";
import { useCallback, useState } from "react";
import type { BlockInteraction, ButtonElement } from "../types.js";
export function ButtonElementComponent({
element,
onAction,
}: {
element: ButtonElement;
onAction: (interaction: BlockInteraction) => void;
}) {
const [confirmOpen, setConfirmOpen] = useState(false);
const fireAction = useCallback(() => {
onAction({
type: "block_action",
action_id: element.action_id,
value: element.value,
});
}, [onAction, element.action_id, element.value]);
const handleClick = useCallback(() => {
if (element.confirm) {
setConfirmOpen(true);
} else {
fireAction();
}
}, [element.confirm, fireAction]);
const handleConfirm = useCallback(() => {
setConfirmOpen(false);
fireAction();
}, [fireAction]);
const variant =
element.style === "primary"
? ("primary" as const)
: element.style === "danger"
? ("destructive" as const)
: ("secondary" as const);
return (
<>
<Button variant={variant} onClick={handleClick}>
{element.label}
</Button>
{element.confirm && (
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
<Dialog>
<h3 className="text-lg font-semibold text-kumo-default">{element.confirm.title}</h3>
<p className="mt-1 text-sm text-kumo-subtle">{element.confirm.text}</p>
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" onClick={() => setConfirmOpen(false)}>
{element.confirm.deny}
</Button>
<Button
variant={element.confirm.style === "danger" ? "destructive" : "primary"}
onClick={handleConfirm}
>
{element.confirm.confirm}
</Button>
</div>
</Dialog>
</DialogRoot>
)}
</>
);
}

View File

@@ -0,0 +1,44 @@
import { Checkbox } from "@cloudflare/kumo";
import { useCallback, useEffect, useState } from "react";
import type { BlockInteraction, CheckboxElement } from "../types.js";
export function CheckboxElementComponent({
element,
onAction,
onChange,
}: {
element: CheckboxElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const [values, setValues] = useState<string[]>(element.initial_value ?? []);
useEffect(() => {
setValues(element.initial_value ?? []);
}, [element.initial_value]);
const handleChange = useCallback(
(newValues: string[]) => {
setValues(newValues);
if (onChange) {
onChange(element.action_id, newValues);
} else {
onAction({
type: "block_action",
action_id: element.action_id,
value: newValues,
});
}
},
[onChange, onAction, element.action_id],
);
return (
<Checkbox.Group legend={element.label} value={values} onValueChange={handleChange}>
{element.options.map((opt) => (
<Checkbox.Item key={opt.value} value={opt.value} label={opt.label} />
))}
</Checkbox.Group>
);
}

View File

@@ -0,0 +1,62 @@
import { Combobox } from "@cloudflare/kumo";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { BlockInteraction, ComboboxElement } from "../types.js";
export function ComboboxElementComponent({
element,
onAction,
onChange,
}: {
element: ComboboxElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const initialOption = useMemo(
() => element.options.find((o) => o.value === element.initial_value) ?? null,
[element.options, element.initial_value],
);
const [selected, setSelected] = useState<{ label: string; value: string } | null>(initialOption);
useEffect(() => {
setSelected(initialOption);
}, [initialOption]);
const handleChange = useCallback(
(newValue: unknown) => {
const opt = newValue as { label: string; value: string } | null;
setSelected(opt);
const val = opt?.value ?? null;
if (onChange) {
onChange(element.action_id, val);
} else {
onAction({
type: "block_action",
action_id: element.action_id,
value: val,
});
}
},
[onChange, onAction, element.action_id],
);
return (
<Combobox
label={element.label}
items={element.options}
value={selected}
onValueChange={handleChange}
>
<Combobox.TriggerInput placeholder={element.placeholder ?? "Search..."} />
<Combobox.Content>
<Combobox.List>
{(item: { label: string; value: string }) => (
<Combobox.Item value={item}>{item.label}</Combobox.Item>
)}
</Combobox.List>
<Combobox.Empty>No results</Combobox.Empty>
</Combobox.Content>
</Combobox>
);
}

View File

@@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import type { BlockInteraction, DateInputElement } from "../types.js";
export function DateInputElementComponent({
element,
onAction,
onChange,
}: {
element: DateInputElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const [value, setValue] = useState(element.initial_value ?? "");
useEffect(() => {
setValue(element.initial_value ?? "");
}, [element.initial_value]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);
if (onChange) {
onChange(element.action_id, newValue);
}
},
[onChange, element.action_id],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
if (!onChange) {
onAction({
type: "block_action",
action_id: element.action_id,
value: e.target.value,
});
}
},
[onChange, onAction, element.action_id],
);
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-kumo-text">{element.label}</label>
<input
type="date"
value={value}
onChange={handleChange}
onBlur={handleBlur}
placeholder={element.placeholder}
className="h-9 rounded-lg border border-kumo-line bg-kumo-bg px-3 text-sm text-kumo-text outline-none focus:ring-2 focus:ring-kumo-ring"
/>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { Input } from "@cloudflare/kumo";
import { useCallback } from "react";
import type { BlockInteraction, NumberInputElement } from "../types.js";
export function NumberInputElementComponent({
element,
onAction,
onChange,
}: {
element: NumberInputElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value === "" ? undefined : Number(e.target.value);
if (onChange) {
onChange(element.action_id, val);
}
},
[onChange, element.action_id],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
if (!onChange) {
const val = e.target.value === "" ? undefined : Number(e.target.value);
onAction({
type: "block_action",
action_id: element.action_id,
value: val,
});
}
},
[onChange, onAction, element.action_id],
);
return (
<Input
label={element.label}
type="number"
min={element.min}
max={element.max}
defaultValue={element.initial_value}
onChange={handleChange}
onBlur={handleBlur}
/>
);
}

View File

@@ -0,0 +1,44 @@
import { Radio } from "@cloudflare/kumo";
import { useCallback, useEffect, useState } from "react";
import type { BlockInteraction, RadioElement } from "../types.js";
export function RadioElementComponent({
element,
onAction,
onChange,
}: {
element: RadioElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const [value, setValue] = useState(element.initial_value ?? "");
useEffect(() => {
setValue(element.initial_value ?? "");
}, [element.initial_value]);
const handleChange = useCallback(
(newValue: string) => {
setValue(newValue);
if (onChange) {
onChange(element.action_id, newValue);
} else {
onAction({
type: "block_action",
action_id: element.action_id,
value: newValue,
});
}
},
[onChange, onAction, element.action_id],
);
return (
<Radio.Group legend={element.label} value={value} onValueChange={handleChange}>
{element.options.map((opt) => (
<Radio.Item key={opt.value} value={opt.value} label={opt.label} />
))}
</Radio.Group>
);
}

View File

@@ -0,0 +1,70 @@
import { SensitiveInput } from "@cloudflare/kumo";
import { useCallback, useState } from "react";
import type { BlockInteraction, SecretInputElement } from "../types.js";
export function SecretInputElementComponent({
element,
onAction,
onChange,
}: {
element: SecretInputElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const [value, setValue] = useState("");
const [editing, setEditing] = useState(!element.has_value);
const handleValueChange = useCallback(
(v: string) => {
setValue(v);
if (onChange) {
onChange(element.action_id, v);
}
},
[onChange, element.action_id],
);
const handleFocus = useCallback(() => {
if (!editing) {
setEditing(true);
setValue("");
}
}, [editing]);
const handleBlur = useCallback(() => {
if (!onChange && value) {
onAction({
type: "block_action",
action_id: element.action_id,
value,
});
}
if (!value && element.has_value) {
setEditing(false);
}
}, [onChange, onAction, element.action_id, value, element.has_value]);
if (!editing) {
return (
<SensitiveInput
label={element.label}
value={"••••••••"}
readOnly
onFocus={handleFocus}
placeholder={element.placeholder}
/>
);
}
return (
<SensitiveInput
label={element.label}
value={value}
onValueChange={handleValueChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={element.placeholder}
/>
);
}

View File

@@ -0,0 +1,43 @@
import { Select } from "@cloudflare/kumo";
import { useCallback } from "react";
import type { BlockInteraction, SelectElement } from "../types.js";
export function SelectElementComponent({
element,
onAction,
onChange,
}: {
element: SelectElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const handleValueChange = useCallback(
(value: unknown) => {
if (onChange) {
onChange(element.action_id, value);
} else {
onAction({
type: "block_action",
action_id: element.action_id,
value,
});
}
},
[onChange, onAction, element.action_id],
);
return (
<Select
label={element.label}
defaultValue={element.initial_value}
onValueChange={handleValueChange}
>
{element.options.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
}

View File

@@ -0,0 +1,58 @@
import { Input, InputArea } from "@cloudflare/kumo";
import { useCallback } from "react";
import type { BlockInteraction, TextInputElement } from "../types.js";
export function TextInputElementComponent({
element,
onAction,
onChange,
}: {
element: TextInputElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (onChange) {
onChange(element.action_id, e.target.value);
}
},
[onChange, element.action_id],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!onChange) {
onAction({
type: "block_action",
action_id: element.action_id,
value: e.target.value,
});
}
},
[onChange, onAction, element.action_id],
);
if (element.multiline) {
return (
<InputArea
label={element.label}
placeholder={element.placeholder}
defaultValue={element.initial_value}
onChange={handleChange}
onBlur={handleBlur}
/>
);
}
return (
<Input
label={element.label}
placeholder={element.placeholder}
defaultValue={element.initial_value}
onChange={handleChange}
onBlur={handleBlur}
/>
);
}

View File

@@ -0,0 +1,34 @@
import { Switch } from "@cloudflare/kumo";
import { useCallback, useState } from "react";
import type { BlockInteraction, ToggleElement } from "../types.js";
export function ToggleElementComponent({
element,
onAction,
onChange,
}: {
element: ToggleElement;
onAction: (interaction: BlockInteraction) => void;
onChange?: (actionId: string, value: unknown) => void;
}) {
const [checked, setChecked] = useState(element.initial_value ?? false);
const handleChange = useCallback(
(value: boolean) => {
setChecked(value);
if (onChange) {
onChange(element.action_id, value);
} else {
onAction({
type: "block_action",
action_id: element.action_id,
value,
});
}
},
[onChange, onAction, element.action_id],
);
return <Switch label={element.label} checked={checked} onCheckedChange={handleChange} />;
}

View File

@@ -0,0 +1,60 @@
export { BlockRenderer } from "./renderer.js";
export type { BlockRendererProps } from "./renderer.js";
export { renderElement } from "./render-element.js";
export { cn, formatRelativeTime } from "./utils.js";
// Builders and validation
export { blocks, elements } from "./builders.js";
export { validateBlocks } from "./validation.js";
// Re-export all types
export type {
// Composition objects
ConfirmDialog,
// Elements
ButtonElement,
TextInputElement,
NumberInputElement,
SelectElement,
ToggleElement,
SecretInputElement,
CheckboxElement,
ComboboxElement,
DateInputElement,
RadioElement,
Element,
// Form
FieldCondition,
FormField,
// Block sub-types
TableColumn,
StatItem,
ChartSeries,
ChartConfig,
TimeseriesChartConfig,
CustomChartConfig,
// Blocks
HeaderBlock,
SectionBlock,
DividerBlock,
FieldsBlock,
TableBlock,
ActionsBlock,
StatsBlock,
FormBlock,
ImageBlock,
ContextBlock,
ColumnsBlock,
ChartBlock,
CodeBlock,
BannerBlock,
MeterBlock,
Block,
// Interactions
BlockAction,
FormSubmit,
PageLoad,
BlockInteraction,
// Response
BlockResponse,
} from "./types.js";

View File

@@ -0,0 +1,52 @@
import { ButtonElementComponent } from "./elements/button.js";
import { CheckboxElementComponent } from "./elements/checkbox.js";
import { ComboboxElementComponent } from "./elements/combobox.js";
import { DateInputElementComponent } from "./elements/date-input.js";
import { NumberInputElementComponent } from "./elements/number-input.js";
import { RadioElementComponent } from "./elements/radio.js";
import { SecretInputElementComponent } from "./elements/secret-input.js";
import { SelectElementComponent } from "./elements/select.js";
import { TextInputElementComponent } from "./elements/text-input.js";
import { ToggleElementComponent } from "./elements/toggle.js";
import type { BlockInteraction, Element } from "./types.js";
export function renderElement(
element: Element,
onAction: (interaction: BlockInteraction) => void,
onChange?: (actionId: string, value: unknown) => void,
): React.ReactNode {
switch (element.type) {
case "button":
return <ButtonElementComponent element={element} onAction={onAction} />;
case "text_input":
return (
<TextInputElementComponent element={element} onAction={onAction} onChange={onChange} />
);
case "number_input":
return (
<NumberInputElementComponent element={element} onAction={onAction} onChange={onChange} />
);
case "select":
return <SelectElementComponent element={element} onAction={onAction} onChange={onChange} />;
case "toggle":
return <ToggleElementComponent element={element} onAction={onAction} onChange={onChange} />;
case "secret_input":
return (
<SecretInputElementComponent element={element} onAction={onAction} onChange={onChange} />
);
case "checkbox":
return <CheckboxElementComponent element={element} onAction={onAction} onChange={onChange} />;
case "radio":
return <RadioElementComponent element={element} onAction={onAction} onChange={onChange} />;
case "date_input":
return (
<DateInputElementComponent element={element} onAction={onAction} onChange={onChange} />
);
case "combobox":
return <ComboboxElementComponent element={element} onAction={onAction} onChange={onChange} />;
default: {
const _exhaustive: never = element;
return null;
}
}
}

View File

@@ -0,0 +1,73 @@
import { ActionsBlockComponent } from "./blocks/actions.js";
import { BannerBlockComponent } from "./blocks/banner.js";
import { ChartBlockComponent } from "./blocks/chart.js";
import { CodeBlockComponent } from "./blocks/code.js";
import { ColumnsBlockComponent } from "./blocks/columns.js";
import { ContextBlockComponent } from "./blocks/context.js";
import { DividerBlockComponent } from "./blocks/divider.js";
import { FieldsBlockComponent } from "./blocks/fields.js";
import { FormBlockComponent } from "./blocks/form.js";
import { HeaderBlockComponent } from "./blocks/header.js";
import { ImageBlockComponent } from "./blocks/image.js";
import { MeterBlockComponent } from "./blocks/meter.js";
import { SectionBlockComponent } from "./blocks/section.js";
import { StatsBlockComponent } from "./blocks/stats.js";
import { TableBlockComponent } from "./blocks/table.js";
import type { Block, BlockInteraction } from "./types.js";
function renderBlock(
block: Block,
onAction: (interaction: BlockInteraction) => void,
): React.ReactNode {
switch (block.type) {
case "header":
return <HeaderBlockComponent block={block} />;
case "section":
return <SectionBlockComponent block={block} onAction={onAction} />;
case "divider":
return <DividerBlockComponent />;
case "fields":
return <FieldsBlockComponent block={block} />;
case "table":
return <TableBlockComponent block={block} onAction={onAction} />;
case "actions":
return <ActionsBlockComponent block={block} onAction={onAction} />;
case "stats":
return <StatsBlockComponent block={block} />;
case "form":
return <FormBlockComponent block={block} onAction={onAction} />;
case "image":
return <ImageBlockComponent block={block} />;
case "context":
return <ContextBlockComponent block={block} />;
case "columns":
return <ColumnsBlockComponent block={block} onAction={onAction} />;
case "chart":
return <ChartBlockComponent block={block} />;
case "meter":
return <MeterBlockComponent block={block} />;
case "banner":
return <BannerBlockComponent block={block} />;
case "code":
return <CodeBlockComponent block={block} />;
default: {
const _exhaustive: never = block;
return null;
}
}
}
export interface BlockRendererProps {
blocks: Block[];
onAction: (interaction: BlockInteraction) => void;
}
export function BlockRenderer({ blocks, onAction }: BlockRendererProps) {
return (
<div className="flex flex-col gap-4">
{blocks.map((block, i) => (
<div key={block.block_id ?? i}>{renderBlock(block, onAction)}</div>
))}
</div>
);
}

View File

@@ -0,0 +1,49 @@
/**
* Server-safe exports for @emdashcms/blocks.
*
* Use this entry point in plugin route handlers and other server-side code
* that doesn't have React available. Provides builders, validation, and types
* without importing any React components.
*/
export { blocks, elements } from "./builders.js";
export { validateBlocks } from "./validation.js";
export type {
// Composition objects
ConfirmDialog,
// Elements
ButtonElement,
TextInputElement,
NumberInputElement,
SelectElement,
ToggleElement,
SecretInputElement,
Element,
// Form
FieldCondition,
FormField,
// Block sub-types
TableColumn,
StatItem,
// Blocks
HeaderBlock,
SectionBlock,
DividerBlock,
FieldsBlock,
TableBlock,
ActionsBlock,
StatsBlock,
FormBlock,
ImageBlock,
ContextBlock,
ColumnsBlock,
Block,
// Interactions
BlockAction,
FormSubmit,
PageLoad,
BlockInteraction,
// Response
BlockResponse,
} from "./types.js";

View File

@@ -0,0 +1,329 @@
// ── Composition Objects ──────────────────────────────────────────────────────
export interface ConfirmDialog {
title: string;
text: string;
confirm: string;
deny: string;
style?: "danger";
}
// ── Elements ─────────────────────────────────────────────────────────────────
export interface ButtonElement {
type: "button";
action_id: string;
label: string;
style?: "primary" | "danger" | "secondary";
value?: unknown;
confirm?: ConfirmDialog;
}
export interface TextInputElement {
type: "text_input";
action_id: string;
label: string;
placeholder?: string;
initial_value?: string;
multiline?: boolean;
}
export interface NumberInputElement {
type: "number_input";
action_id: string;
label: string;
initial_value?: number;
min?: number;
max?: number;
}
export interface SelectElement {
type: "select";
action_id: string;
label: string;
options: Array<{ label: string; value: string }>;
initial_value?: string;
/** Plugin route that returns `{ items: Array<{ id, name }> }` to populate options dynamically */
optionsRoute?: string;
}
export interface ToggleElement {
type: "toggle";
action_id: string;
label: string;
description?: string;
initial_value?: boolean;
}
export interface SecretInputElement {
type: "secret_input";
action_id: string;
label: string;
placeholder?: string;
has_value?: boolean;
}
export interface CheckboxElement {
type: "checkbox";
action_id: string;
label: string;
options: Array<{ label: string; value: string }>;
initial_value?: string[];
}
export interface DateInputElement {
type: "date_input";
action_id: string;
label: string;
initial_value?: string;
placeholder?: string;
}
export interface ComboboxElement {
type: "combobox";
action_id: string;
label: string;
options: Array<{ label: string; value: string }>;
initial_value?: string;
placeholder?: string;
}
export interface RadioElement {
type: "radio";
action_id: string;
label: string;
options: Array<{ label: string; value: string }>;
initial_value?: string;
}
export type Element =
| ButtonElement
| TextInputElement
| NumberInputElement
| SelectElement
| ToggleElement
| SecretInputElement
| CheckboxElement
| DateInputElement
| ComboboxElement
| RadioElement;
// ── Form Fields (elements + optional condition) ──────────────────────────────
export type FieldCondition =
| { field: string; eq?: unknown; neq?: never }
| { field: string; neq?: unknown; eq?: never };
export type FormField = (
| ButtonElement
| TextInputElement
| NumberInputElement
| SelectElement
| ToggleElement
| SecretInputElement
| CheckboxElement
| DateInputElement
| ComboboxElement
| RadioElement
) & {
condition?: FieldCondition;
};
// ── Block Sub-types ──────────────────────────────────────────────────────────
export interface TableColumn {
key: string;
label: string;
format?: "text" | "badge" | "relative_time" | "number" | "code";
sortable?: boolean;
}
export interface StatItem {
label: string;
value: string | number;
description?: string;
trend?: "up" | "down" | "neutral";
}
/** A single data series for a timeseries chart. */
export interface ChartSeries {
/** Display name shown in tooltips and legends */
name: string;
/** Array of `[timestamp_ms, value]` tuples ordered by time */
data: [number, number][];
/**
* Hex color for this series. If omitted, an automatic categorical color
* from the Kumo palette is assigned based on the series index.
*/
color?: string;
}
/** Timeseries-specific chart configuration */
export interface TimeseriesChartConfig {
chart_type: "timeseries";
/** Visual style of each series. Defaults to `"line"`. */
style?: "line" | "bar";
/** Array of time series to display */
series: ChartSeries[];
/** Label for the x-axis */
x_axis_name?: string;
/** Label for the y-axis */
y_axis_name?: string;
/** Height of the chart in pixels. Defaults to 350. */
height?: number;
/** Render a gradient fill beneath line series */
gradient?: boolean;
}
/** Custom chart configuration using raw ECharts options (pie, etc.) */
export interface CustomChartConfig {
chart_type: "custom";
/** Raw ECharts option object — passed through to `chart.setOption()` */
options: Record<string, unknown>;
/** Height of the chart in pixels. Defaults to 350. */
height?: number;
}
export type ChartConfig = TimeseriesChartConfig | CustomChartConfig;
// ── Blocks ───────────────────────────────────────────────────────────────────
interface BlockBase {
block_id?: string;
}
export interface HeaderBlock extends BlockBase {
type: "header";
text: string;
}
export interface SectionBlock extends BlockBase {
type: "section";
text: string;
accessory?: Element;
}
export interface DividerBlock extends BlockBase {
type: "divider";
}
export interface FieldsBlock extends BlockBase {
type: "fields";
fields: Array<{ label: string; value: string }>;
}
export interface TableBlock extends BlockBase {
type: "table";
columns: TableColumn[];
rows: Array<Record<string, unknown>>;
next_cursor?: string;
page_action_id: string;
empty_text?: string;
}
export interface ActionsBlock extends BlockBase {
type: "actions";
elements: Element[];
}
export interface StatsBlock extends BlockBase {
type: "stats";
items: StatItem[];
}
export interface FormBlock extends BlockBase {
type: "form";
fields: FormField[];
submit: { label: string; action_id: string };
}
export interface ImageBlock extends BlockBase {
type: "image";
url: string;
alt: string;
title?: string;
}
export interface ContextBlock extends BlockBase {
type: "context";
text: string;
}
export interface ColumnsBlock extends BlockBase {
type: "columns";
columns: Block[][];
}
export interface ChartBlock extends BlockBase {
type: "chart";
config: ChartConfig;
}
export interface BannerBlock extends BlockBase {
type: "banner";
title?: string;
description?: string;
variant?: "default" | "alert" | "error";
}
export interface MeterBlock extends BlockBase {
type: "meter";
label: string;
value: number;
max?: number;
min?: number;
custom_value?: string;
}
export interface CodeBlock extends BlockBase {
type: "code";
code: string;
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
}
export type Block =
| HeaderBlock
| SectionBlock
| DividerBlock
| FieldsBlock
| TableBlock
| ActionsBlock
| StatsBlock
| FormBlock
| ImageBlock
| ContextBlock
| ColumnsBlock
| ChartBlock
| BannerBlock
| MeterBlock
| CodeBlock;
// ── Interactions ─────────────────────────────────────────────────────────────
export interface BlockAction {
type: "block_action";
action_id: string;
block_id?: string;
value?: unknown;
}
export interface FormSubmit {
type: "form_submit";
action_id: string;
block_id?: string;
values: Record<string, unknown>;
}
export interface PageLoad {
type: "page_load";
page: string;
}
export type BlockInteraction = BlockAction | FormSubmit | PageLoad;
// ── Response ─────────────────────────────────────────────────────────────────
export interface BlockResponse {
blocks: Block[];
toast?: { message: string; type: "success" | "error" | "info" };
}

View File

@@ -0,0 +1,93 @@
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: Parameters<typeof clsx>) {
return twMerge(clsx(inputs));
}
/**
* Detects dark mode from `<html data-theme="dark">` or the system
* `prefers-color-scheme` media query and stays in sync reactively.
*/
export function useIsDarkMode(): boolean {
const [dark, setDark] = useState(() => {
if (typeof document === "undefined") return false;
const attr = document.documentElement.getAttribute("data-theme");
if (attr === "dark") return true;
if (attr === "light") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
useEffect(() => {
// Watch for data-theme attribute changes on <html>
const observer = new MutationObserver(() => {
const attr = document.documentElement.getAttribute("data-theme");
if (attr === "dark") return setDark(true);
if (attr === "light") return setDark(false);
setDark(window.matchMedia("(prefers-color-scheme: dark)").matches);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
// Also watch the system media query
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
if (!document.documentElement.hasAttribute("data-theme")) {
setDark(e.matches);
}
};
mq.addEventListener("change", handler);
return () => {
observer.disconnect();
mq.removeEventListener("change", handler);
};
}, []);
return dark;
}
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
export function formatRelativeTime(iso: string): string {
const date = new Date(iso);
const now = Date.now();
const diff = Math.floor((now - date.getTime()) / 1000);
if (diff < 0) {
return "just now";
}
if (diff < MINUTE) {
return "just now";
}
if (diff < HOUR) {
const mins = Math.floor(diff / MINUTE);
return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
}
if (diff < DAY) {
const hours = Math.floor(diff / HOUR);
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
}
if (diff < WEEK) {
const days = Math.floor(diff / DAY);
return days === 1 ? "1 day ago" : `${days} days ago`;
}
if (diff < MONTH) {
const weeks = Math.floor(diff / WEEK);
return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
}
if (diff < YEAR) {
const months = Math.floor(diff / MONTH);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
const years = Math.floor(diff / YEAR);
return years === 1 ? "1 year ago" : `${years} years ago`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,403 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BlockRenderer } from "../src/renderer.js";
import type { BlockInteraction, FormBlock } from "../src/types.js";
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, variant, type }: any) => (
<button onClick={onClick} data-variant={variant} type={type || "button"}>
{children}
</button>
),
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
<div>
<label>{label}</label>
<input
type={type || "text"}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
min={min}
max={max}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
InputArea: ({ label, defaultValue, onChange, onBlur, placeholder }: any) => (
<div>
<label>{label}</label>
<textarea
defaultValue={defaultValue}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
Select: Object.assign(
({ children, label, defaultValue, onValueChange }: any) => (
<div>
<label>{label}</label>
<select defaultValue={defaultValue} onChange={(e: any) => onValueChange?.(e.target.value)}>
{children}
</select>
</div>
),
{
Option: ({ children, value }: any) => <option value={value}>{children}</option>,
},
),
Switch: ({ label, checked, onCheckedChange }: any) => (
<div>
<label>
<input
type="checkbox"
checked={checked}
onChange={(e: any) => onCheckedChange?.(e.target.checked)}
/>
{label}
</label>
</div>
),
SensitiveInput: ({
label,
value,
onValueChange,
readOnly,
onFocus,
onBlur,
placeholder,
}: any) => (
<div>
<label>{label}</label>
<input
type="password"
value={value}
readOnly={readOnly}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
onChange={(e: any) => onValueChange?.(e.target.value)}
/>
</div>
),
Dialog: ({ children }: any) => <div data-testid="dialog">{children}</div>,
DialogRoot: ({ children, open }: any) =>
open ? <div data-testid="dialog-root">{children}</div> : null,
Banner: ({ title, description, variant, icon }: any) => (
<div data-testid="banner" data-variant={variant}>
{icon}
{title && <strong>{title}</strong>}
{description && <p>{description}</p>}
</div>
),
Meter: ({ label, value, max, min, customValue }: any) => (
<div data-testid="meter" data-value={value} data-max={max} data-min={min}>
<span>{label}</span>
{customValue && <span>{customValue}</span>}
</div>
),
CodeBlock: ({ code, lang }: any) => (
<pre data-testid="code-block" data-lang={lang}>
<code>{code}</code>
</pre>
),
Checkbox: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="checkbox-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="checkbox" value={value} />
{label}
</label>
),
},
Radio: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="radio-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="radio" value={value} />
{label}
</label>
),
},
Combobox: Object.assign(
({ children, label }: any) => (
<div data-testid="combobox">
<label>{label}</label>
{children}
</div>
),
{
TriggerInput: ({ placeholder }: any) => <input placeholder={placeholder} />,
Content: ({ children }: any) => <div>{children}</div>,
List: ({ children }: any) => <div>{typeof children === "function" ? null : children}</div>,
Item: ({ children, value }: any) => <div data-value={value}>{children}</div>,
Empty: ({ children }: any) => <div>{children}</div>,
},
),
}));
vi.mock("@cloudflare/kumo/components/chart", () => ({
TimeseriesChart: (props: any) => (
<div data-testid="timeseries-chart" data-height={props.height} />
),
Chart: (props: any) => <div data-testid="custom-chart" data-height={props.height} />,
ChartPalette: { color: (i: number) => `#color${i}` },
}));
// eslint-disable-next-line unicorn/consistent-function-scoping -- vi.mock is hoisted; cannot reference outer scope
vi.mock("echarts/core", () => {
const noop = () => {};
return { __esModule: true, default: { use: noop }, use: noop };
});
vi.mock("echarts/charts", () => ({
BarChart: {},
LineChart: {},
PieChart: {},
}));
vi.mock("echarts/components", () => ({
AriaComponent: {},
AxisPointerComponent: {},
GridComponent: {},
TooltipComponent: {},
}));
vi.mock("echarts/renderers", () => ({
CanvasRenderer: {},
}));
vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => <span data-testid="arrow-up" />,
ArrowDown: () => <span data-testid="arrow-down" />,
Minus: () => <span data-testid="minus" />,
Info: () => <span data-testid="icon-info" />,
Warning: () => <span data-testid="icon-warning" />,
WarningCircle: () => <span data-testid="icon-warning-circle" />,
}));
afterEach(cleanup);
// ── Helpers ──────────────────────────────────────────────────────────────────
function renderForm(form: FormBlock, onAction?: (i: BlockInteraction) => void) {
const handler = onAction ?? vi.fn();
return {
...render(<BlockRenderer blocks={[form]} onAction={handler} />),
onAction: handler,
};
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("form conditional fields", () => {
it("condition eq: field shown when condition met", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "enable",
label: "Enable",
initial_value: true,
},
{
type: "text_input",
action_id: "name",
label: "Name",
condition: { field: "enable", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Toggle is true → Name field should be visible
expect(screen.getByText("Name")).toBeTruthy();
});
it("condition eq: field hidden when condition not met", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "enable",
label: "Enable",
initial_value: false,
},
{
type: "text_input",
action_id: "name",
label: "Name",
condition: { field: "enable", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Toggle is false → Name field should not be rendered
expect(screen.queryByText("Name")).toBeNull();
});
it("condition neq: field shown when value differs", () => {
renderForm({
type: "form",
fields: [
{
type: "select",
action_id: "status",
label: "Status",
options: [
{ label: "Active", value: "active" },
{ label: "Disabled", value: "disabled" },
],
initial_value: "active",
},
{
type: "text_input",
action_id: "reason",
label: "Reason",
condition: { field: "status", neq: "disabled" },
},
],
submit: { label: "Save", action_id: "save" },
});
// Status is "active" which is != "disabled" → Reason field visible
expect(screen.getByText("Reason")).toBeTruthy();
});
it("condition reacts to changes", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "show_extra",
label: "Show extra",
initial_value: false,
},
{
type: "text_input",
action_id: "extra",
label: "Extra field",
condition: { field: "show_extra", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Initially hidden
expect(screen.queryByText("Extra field")).toBeNull();
// Click toggle to enable
const toggle = screen.getByRole("checkbox");
fireEvent.click(toggle);
// Now visible
expect(screen.getByText("Extra field")).toBeTruthy();
});
it("hidden field values are included in submit payload", () => {
const onAction = vi.fn();
renderForm(
{
type: "form",
fields: [
{
type: "toggle",
action_id: "show_name",
label: "Show name",
initial_value: true,
},
{
type: "text_input",
action_id: "name",
label: "Name",
initial_value: "Alice",
condition: { field: "show_name", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
},
onAction,
);
// Field is visible, then hide it by toggling off
const toggle = screen.getByRole("checkbox");
fireEvent.click(toggle);
// Name field is now hidden
expect(screen.queryByText("Name")).toBeNull();
// Submit — hidden field's last known value should still be in payload
fireEvent.click(screen.getByText("Save"));
expect(onAction).toHaveBeenCalledWith(
expect.objectContaining({
type: "form_submit",
action_id: "save",
values: expect.objectContaining({
show_name: false,
name: "Alice",
}),
}),
);
});
it("multiple conditional fields evaluate independently", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "toggle_a",
label: "Toggle A",
initial_value: true,
},
{
type: "toggle",
action_id: "toggle_b",
label: "Toggle B",
initial_value: false,
},
{
type: "text_input",
action_id: "field_a",
label: "Field A",
condition: { field: "toggle_a", eq: true },
},
{
type: "text_input",
action_id: "field_b",
label: "Field B",
condition: { field: "toggle_b", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Toggle A is true → Field A visible
expect(screen.getByText("Field A")).toBeTruthy();
// Toggle B is false → Field B hidden
expect(screen.queryByText("Field B")).toBeNull();
});
});

View File

@@ -0,0 +1,494 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BlockRenderer } from "../src/renderer.js";
import type { Block, BlockInteraction } from "../src/types.js";
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, variant, type }: any) => (
<button onClick={onClick} data-variant={variant} type={type || "button"}>
{children}
</button>
),
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
<div>
<label>{label}</label>
<input
type={type || "text"}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
min={min}
max={max}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
InputArea: ({ label, defaultValue, onChange, onBlur, placeholder }: any) => (
<div>
<label>{label}</label>
<textarea
defaultValue={defaultValue}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
Select: Object.assign(
({ children, label, defaultValue, onValueChange }: any) => (
<div>
<label>{label}</label>
<select defaultValue={defaultValue} onChange={(e: any) => onValueChange?.(e.target.value)}>
{children}
</select>
</div>
),
{
Option: ({ children, value }: any) => <option value={value}>{children}</option>,
},
),
Switch: ({ label, checked, onCheckedChange }: any) => (
<div>
<label>
<input
type="checkbox"
checked={checked}
onChange={(e: any) => onCheckedChange?.(e.target.checked)}
/>
{label}
</label>
</div>
),
SensitiveInput: ({
label,
value,
onValueChange,
readOnly,
onFocus,
onBlur,
placeholder,
}: any) => (
<div>
<label>{label}</label>
<input
type="password"
value={value}
readOnly={readOnly}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
onChange={(e: any) => onValueChange?.(e.target.value)}
/>
</div>
),
Dialog: ({ children }: any) => <div data-testid="dialog">{children}</div>,
DialogRoot: ({ children, open }: any) =>
open ? <div data-testid="dialog-root">{children}</div> : null,
Banner: ({ title, description, variant, icon }: any) => (
<div data-testid="banner" data-variant={variant}>
{icon}
{title && <strong>{title}</strong>}
{description && <p>{description}</p>}
</div>
),
Meter: ({ label, value, max, min, customValue }: any) => (
<div data-testid="meter" data-value={value} data-max={max} data-min={min}>
<span>{label}</span>
{customValue && <span>{customValue}</span>}
</div>
),
CodeBlock: ({ code, lang }: any) => (
<pre data-testid="code-block" data-lang={lang}>
<code>{code}</code>
</pre>
),
Checkbox: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="checkbox-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="checkbox" value={value} />
{label}
</label>
),
},
Radio: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="radio-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="radio" value={value} />
{label}
</label>
),
},
Combobox: Object.assign(
({ children, label }: any) => (
<div data-testid="combobox">
<label>{label}</label>
{children}
</div>
),
{
TriggerInput: ({ placeholder }: any) => <input placeholder={placeholder} />,
Content: ({ children }: any) => <div>{children}</div>,
List: ({ children }: any) => <div>{typeof children === "function" ? null : children}</div>,
Item: ({ children, value }: any) => <div data-value={value}>{children}</div>,
Empty: ({ children }: any) => <div>{children}</div>,
},
),
}));
vi.mock("@cloudflare/kumo/components/chart", () => ({
TimeseriesChart: (props: any) => (
<div data-testid="timeseries-chart" data-height={props.height} />
),
Chart: (props: any) => <div data-testid="custom-chart" data-height={props.height} />,
ChartPalette: { color: (i: number) => `#color${i}` },
}));
// eslint-disable-next-line unicorn/consistent-function-scoping -- vi.mock is hoisted; cannot reference outer scope
vi.mock("echarts/core", () => {
const noop = () => {};
return { __esModule: true, default: { use: noop }, use: noop };
});
vi.mock("echarts/charts", () => ({
BarChart: {},
LineChart: {},
PieChart: {},
}));
vi.mock("echarts/components", () => ({
AriaComponent: {},
AxisPointerComponent: {},
GridComponent: {},
TooltipComponent: {},
}));
vi.mock("echarts/renderers", () => ({
CanvasRenderer: {},
}));
vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => <span data-testid="arrow-up" />,
ArrowDown: () => <span data-testid="arrow-down" />,
Minus: () => <span data-testid="minus" />,
Info: () => <span data-testid="icon-info" />,
Warning: () => <span data-testid="icon-warning" />,
WarningCircle: () => <span data-testid="icon-warning-circle" />,
}));
afterEach(cleanup);
// ── Helpers ──────────────────────────────────────────────────────────────────
function renderBlocks(blocks: Block[], onAction?: (i: BlockInteraction) => void) {
const handler = onAction ?? vi.fn();
return { ...render(<BlockRenderer blocks={blocks} onAction={handler} />), onAction: handler };
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("BlockRenderer", () => {
it("header block renders h2 with text", () => {
renderBlocks([{ type: "header", text: "Settings" }]);
const heading = screen.getByText("Settings");
expect(heading.tagName).toBe("H2");
});
it("section block renders text", () => {
renderBlocks([{ type: "section", text: "Configure your integration." }]);
expect(screen.getByText("Configure your integration.")).toBeTruthy();
});
it("section block renders accessory button", () => {
renderBlocks([
{
type: "section",
text: "Webhook endpoint",
accessory: { type: "button", action_id: "edit", label: "Edit" },
},
]);
expect(screen.getByText("Webhook endpoint")).toBeTruthy();
expect(screen.getByText("Edit")).toBeTruthy();
});
it("divider block renders hr", () => {
const { container } = renderBlocks([{ type: "divider" }]);
expect(container.querySelector("hr")).toBeTruthy();
});
it("fields block renders labels and values in grid", () => {
renderBlocks([
{
type: "fields",
fields: [
{ label: "Status", value: "Active" },
{ label: "Plan", value: "Pro" },
],
},
]);
expect(screen.getByText("Status")).toBeTruthy();
expect(screen.getByText("Active")).toBeTruthy();
expect(screen.getByText("Plan")).toBeTruthy();
expect(screen.getByText("Pro")).toBeTruthy();
});
it("table block renders column headers and row data", () => {
renderBlocks([
{
type: "table",
columns: [
{ key: "name", label: "Name" },
{ key: "role", label: "Role" },
],
rows: [{ name: "Alice", role: "Admin" }],
page_action_id: "page",
},
]);
expect(screen.getByText("Name")).toBeTruthy();
expect(screen.getByText("Role")).toBeTruthy();
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Admin")).toBeTruthy();
});
it("table block shows empty_text when rows empty", () => {
renderBlocks([
{
type: "table",
columns: [{ key: "name", label: "Name" }],
rows: [],
page_action_id: "page",
empty_text: "No items found",
},
]);
expect(screen.getByText("No items found")).toBeTruthy();
});
it("table badge format renders Badge component", () => {
renderBlocks([
{
type: "table",
columns: [{ key: "status", label: "Status", format: "badge" as const }],
rows: [{ status: "Active" }],
page_action_id: "page",
},
]);
expect(screen.getByTestId("badge")).toBeTruthy();
expect(screen.getByTestId("badge").textContent).toBe("Active");
});
it("actions block renders buttons horizontally", () => {
renderBlocks([
{
type: "actions",
elements: [
{ type: "button", action_id: "a1", label: "Save" },
{ type: "button", action_id: "a2", label: "Cancel" },
],
},
]);
expect(screen.getByText("Save")).toBeTruthy();
expect(screen.getByText("Cancel")).toBeTruthy();
});
it("stats block renders stat cards with values", () => {
renderBlocks([
{
type: "stats",
items: [
{ label: "Posts", value: 120 },
{ label: "Users", value: "5k" },
],
},
]);
expect(screen.getByText("Posts")).toBeTruthy();
expect(screen.getByText("120")).toBeTruthy();
expect(screen.getByText("Users")).toBeTruthy();
expect(screen.getByText("5k")).toBeTruthy();
});
it("stats block renders trend arrows", () => {
renderBlocks([
{
type: "stats",
items: [
{ label: "Revenue", value: 100, trend: "up" },
{ label: "Errors", value: 3, trend: "down" },
{ label: "Latency", value: "50ms", trend: "neutral" },
],
},
]);
expect(screen.getByTestId("arrow-up")).toBeTruthy();
expect(screen.getByTestId("arrow-down")).toBeTruthy();
expect(screen.getByTestId("minus")).toBeTruthy();
});
it("form block renders fields and submit button", () => {
renderBlocks([
{
type: "form",
fields: [{ type: "text_input", action_id: "title", label: "Title" }],
submit: { label: "Create", action_id: "create" },
},
]);
expect(screen.getByText("Title")).toBeTruthy();
expect(screen.getByText("Create")).toBeTruthy();
});
it("form onAction fires form_submit with collected values", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "form",
block_id: "my_form",
fields: [
{
type: "text_input",
action_id: "title",
label: "Title",
initial_value: "Hello",
},
{
type: "toggle",
action_id: "published",
label: "Published",
initial_value: true,
},
],
submit: { label: "Save", action_id: "save_post" },
},
],
onAction,
);
// Submit the form
const submitBtn = screen.getByText("Save");
fireEvent.click(submitBtn);
expect(onAction).toHaveBeenCalledWith({
type: "form_submit",
action_id: "save_post",
block_id: "my_form",
values: { title: "Hello", published: true },
});
});
it("image block renders img with src and alt", () => {
renderBlocks([
{
type: "image",
url: "https://example.com/photo.jpg",
alt: "A photo",
},
]);
const img = screen.getByAltText("A photo") as HTMLImageElement;
expect(img.src).toBe("https://example.com/photo.jpg");
});
it("context block renders small muted text", () => {
renderBlocks([{ type: "context", text: "Updated just now" }]);
const el = screen.getByText("Updated just now");
expect(el.tagName).toBe("P");
expect(el.className).toContain("text-sm");
});
it("columns block renders blocks in columns", () => {
renderBlocks([
{
type: "columns",
columns: [[{ type: "header", text: "Left" }], [{ type: "header", text: "Right" }]],
},
]);
expect(screen.getByText("Left")).toBeTruthy();
expect(screen.getByText("Right")).toBeTruthy();
});
it("button click fires onAction with block_action", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "actions",
elements: [
{
type: "button",
action_id: "do_thing",
label: "Do thing",
value: { id: 42 },
},
],
},
],
onAction,
);
fireEvent.click(screen.getByText("Do thing"));
expect(onAction).toHaveBeenCalledWith({
type: "block_action",
action_id: "do_thing",
value: { id: 42 },
});
});
it("button with confirm shows dialog, confirm fires action", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "actions",
elements: [
{
type: "button",
action_id: "delete_item",
label: "Delete",
style: "danger",
value: "item_1",
confirm: {
title: "Delete item?",
text: "This cannot be undone.",
confirm: "Yes, delete",
deny: "Cancel",
},
},
],
},
],
onAction,
);
// Initially no dialog
expect(screen.queryByTestId("dialog-root")).toBeNull();
// Click button — dialog appears
fireEvent.click(screen.getByText("Delete"));
expect(screen.getByTestId("dialog-root")).toBeTruthy();
expect(screen.getByText("Delete item?")).toBeTruthy();
expect(screen.getByText("This cannot be undone.")).toBeTruthy();
// Click confirm — fires action
fireEvent.click(screen.getByText("Yes, delete"));
expect(onAction).toHaveBeenCalledWith({
type: "block_action",
action_id: "delete_item",
value: "item_1",
});
});
});

View File

@@ -0,0 +1,434 @@
import { describe, expect, it } from "vitest";
import { validateBlocks } from "../src/validation.js";
describe("validateBlocks", () => {
// ── Valid blocks ─────────────────────────────────────────────────────────
describe("valid blocks", () => {
it("header", () => {
const result = validateBlocks([{ type: "header", text: "Hello" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("section", () => {
const result = validateBlocks([{ type: "section", text: "Body text" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("divider", () => {
const result = validateBlocks([{ type: "divider" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("fields", () => {
const result = validateBlocks([
{
type: "fields",
fields: [{ label: "Status", value: "Active" }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("table", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ key: "name", label: "Name" }],
rows: [{ name: "Alice" }],
page_action_id: "load_page",
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("actions", () => {
const result = validateBlocks([
{
type: "actions",
elements: [{ type: "button", action_id: "btn1", label: "Click me" }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("stats", () => {
const result = validateBlocks([
{
type: "stats",
items: [{ label: "Users", value: 42 }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("form", () => {
const result = validateBlocks([
{
type: "form",
fields: [{ type: "text_input", action_id: "name", label: "Name" }],
submit: { label: "Save", action_id: "save" },
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("image", () => {
const result = validateBlocks([
{ type: "image", url: "https://example.com/img.png", alt: "Photo" },
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("context", () => {
const result = validateBlocks([{ type: "context", text: "Last updated 5m ago" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("columns", () => {
const result = validateBlocks([
{
type: "columns",
columns: [[{ type: "header", text: "Left" }], [{ type: "header", text: "Right" }]],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
});
// ── Invalid blocks ───────────────────────────────────────────────────────
describe("invalid blocks", () => {
it("not an array", () => {
const result = validateBlocks("not an array");
expect(result.valid).toBe(false);
expect(result.errors).toEqual([{ path: "blocks", message: "Blocks must be an array" }]);
});
it("block without type", () => {
const result = validateBlocks([{ text: "hello" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].type");
expect(result.errors[0]!.message).toContain("Unknown block type");
});
it("block with unknown type", () => {
const result = validateBlocks([{ type: "foobar" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].type");
expect(result.errors[0]!.message).toContain("Unknown block type 'foobar'");
});
it("header missing text", () => {
const result = validateBlocks([{ type: "header" }]);
expect(result.valid).toBe(false);
expect(result.errors).toEqual([
{
path: "blocks[0].text",
message: "Required field 'text' must be a string",
},
]);
});
it("section missing text", () => {
const result = validateBlocks([{ type: "section" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].text");
});
it("table missing required fields", () => {
const result = validateBlocks([{ type: "table" }]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].columns");
expect(paths).toContain("blocks[0].rows");
expect(paths).toContain("blocks[0].page_action_id");
});
it("table column missing key or label", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ format: "text" }],
rows: [],
page_action_id: "p",
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].columns[0].key");
expect(paths).toContain("blocks[0].columns[0].label");
});
it("table column with invalid format", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ key: "k", label: "K", format: "html" }],
rows: [],
page_action_id: "p",
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns[0].format");
expect(result.errors[0]!.message).toContain("format");
});
it("form missing fields or submit", () => {
const result = validateBlocks([{ type: "form" }]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].fields");
expect(paths).toContain("blocks[0].submit");
});
it("form submit missing action_id", () => {
const result = validateBlocks([
{
type: "form",
fields: [],
submit: { label: "Save" },
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].submit.action_id");
});
it("actions with invalid elements", () => {
const result = validateBlocks([
{
type: "actions",
elements: [{ type: "invalid_type" }],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].type");
expect(result.errors[0]!.message).toContain("Unknown element type");
});
it("select with empty options array", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "select",
action_id: "sel",
label: "Pick",
options: [],
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].options");
expect(result.errors[0]!.message).toContain("must not be empty");
});
it("select option missing label/value", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "select",
action_id: "sel",
label: "Pick",
options: [{ foo: "bar" }],
},
],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].elements[0].options[0].label");
expect(paths).toContain("blocks[0].elements[0].options[0].value");
});
it("button with invalid style", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "button",
action_id: "btn",
label: "Go",
style: "bold",
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].style");
});
it("confirm dialog missing required fields", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "button",
action_id: "btn",
label: "Delete",
confirm: {},
},
],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].elements[0].confirm.title");
expect(paths).toContain("blocks[0].elements[0].confirm.text");
expect(paths).toContain("blocks[0].elements[0].confirm.confirm");
expect(paths).toContain("blocks[0].elements[0].confirm.deny");
});
it("image missing url or alt", () => {
const result = validateBlocks([{ type: "image" }]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].url");
expect(paths).toContain("blocks[0].alt");
});
it("columns with less than 2 arrays", () => {
const result = validateBlocks([
{
type: "columns",
columns: [[{ type: "divider" }]],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns");
expect(result.errors[0]!.message).toContain("2-3 column arrays");
});
it("columns with more than 3 arrays", () => {
const result = validateBlocks([
{
type: "columns",
columns: [
[{ type: "divider" }],
[{ type: "divider" }],
[{ type: "divider" }],
[{ type: "divider" }],
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.message).toContain("2-3 column arrays");
});
it("columns with invalid nested blocks reports correct path", () => {
const result = validateBlocks([
{
type: "columns",
columns: [
[{ type: "header", text: "OK" }],
[{ type: "header" }], // missing text
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns[1][0].text");
});
it("stats item missing label or value", () => {
const result = validateBlocks([
{
type: "stats",
items: [{ description: "desc" }],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].items[0].label");
expect(paths).toContain("blocks[0].items[0].value");
});
it("stats item with invalid trend", () => {
const result = validateBlocks([
{
type: "stats",
items: [{ label: "Users", value: 10, trend: "sideways" }],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].items[0].trend");
});
it("form field with invalid condition (no eq/neq)", () => {
const result = validateBlocks([
{
type: "form",
fields: [
{
type: "text_input",
action_id: "name",
label: "Name",
condition: { field: "toggle" },
},
],
submit: { label: "Save", action_id: "save" },
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].fields[0].condition");
expect(result.errors[0]!.message).toContain("either 'eq' or 'neq'");
});
});
// ── Edge cases ───────────────────────────────────────────────────────────
describe("edge cases", () => {
it("empty blocks array is valid", () => {
const result = validateBlocks([]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("deeply nested columns validate recursively", () => {
const result = validateBlocks([
{
type: "columns",
columns: [
[
{
type: "columns",
columns: [
[{ type: "header", text: "Deep left" }],
[{ type: "header" }], // missing text
],
},
],
[{ type: "divider" }],
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns[0][0].columns[1][0].text");
});
it("multiple errors in one block are all reported", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ format: "invalid" }], // missing key, label, bad format
rows: "not an array",
// missing page_action_id
},
]);
expect(result.valid).toBe(false);
// Should have errors for key, label, format, rows, and page_action_id
expect(result.errors.length).toBeGreaterThanOrEqual(4);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].columns[0].key");
expect(paths).toContain("blocks[0].columns[0].label");
expect(paths).toContain("blocks[0].columns[0].format");
expect(paths).toContain("blocks[0].rows");
expect(paths).toContain("blocks[0].page_action_id");
});
});
});

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts", "src/server.ts"],
format: ["esm"],
dts: true,
clean: true,
platform: "browser",
external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"],
});

View File

@@ -0,0 +1,9 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
},
});