Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { Collapsible } from "@cloudflare/kumo";
import { useState } from "react";
import { BlockRenderer } from "../renderer.js";
import type { AccordionBlock, BlockInteraction } from "../types.js";
export function AccordionBlockComponent({
block,
onAction,
}: {
block: AccordionBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
const [open, setOpen] = useState(block.default_open ?? false);
return (
<Collapsible label={block.label} open={open} onOpenChange={setOpen}>
<BlockRenderer blocks={block.blocks} onAction={onAction} />
</Collapsible>
);
}

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,33 @@
import { Empty } from "@cloudflare/kumo";
import { Package } from "@phosphor-icons/react";
import { renderElement } from "../render-element.js";
import type { BlockInteraction, EmptyBlock } from "../types.js";
export function EmptyBlockComponent({
block,
onAction,
}: {
block: EmptyBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
const contents =
block.actions && block.actions.length > 0 ? (
<div className="flex flex-wrap justify-center gap-2">
{block.actions.map((el, i) => (
<div key={el.action_id ?? i}>{renderElement(el, onAction)}</div>
))}
</div>
) : undefined;
return (
<Empty
icon={<Package size={48} weight="duotone" />}
title={block.title}
description={block.description}
commandLine={block.command_line}
size={block.size}
contents={contents}
/>
);
}

View File

@@ -0,0 +1,16 @@
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 truncate" title={field.value}>
{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,30 @@
import { Tabs } from "@cloudflare/kumo";
import { useState } from "react";
import { BlockRenderer } from "../renderer.js";
import type { BlockInteraction, TabBlock } from "../types.js";
export function TabBlockComponent({
block,
onAction,
}: {
block: TabBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
const [activeTab, setActiveTab] = useState(block.default_tab ?? 0);
const tabs = block.panels.map((panel, i) => ({ value: String(i), label: panel.label }));
return (
<div>
<Tabs
variant="underline"
value={String(activeTab)}
onValueChange={(value) => setActiveTab(Number(value))}
tabs={tabs}
/>
<div className="pt-4">
<BlockRenderer blocks={block.panels[activeTab]?.blocks ?? []} onAction={onAction} />
</div>
</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-start 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,535 @@
import type {
AccordionBlock,
ActionsBlock,
BannerBlock,
Block,
ButtonElement,
CheckboxElement,
ChartBlock,
ChartSeries,
CodeBlock,
ComboboxElement,
ColumnsBlock,
ConfirmDialog,
ContextBlock,
DateInputElement,
RadioElement,
RepeaterElement,
RepeaterSubField,
DividerBlock,
Element,
EmptyBlock,
FieldsBlock,
FormBlock,
FormField,
HeaderBlock,
ImageBlock,
MediaPickerElement,
MeterBlock,
NumberInputElement,
SecretInputElement,
SectionBlock,
SelectElement,
StatItem,
StatsBlock,
TableBlock,
TableColumn,
TextInputElement,
ToggleElement,
TabBlock,
TabPanel,
} 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 repeater(
actionId: string,
label: string,
fields: RepeaterSubField[],
opts?: {
itemLabel?: string;
minItems?: number;
maxItems?: number;
initialValue?: Array<Record<string, unknown>>;
},
): RepeaterElement {
return {
type: "repeater",
action_id: actionId,
label,
fields,
...(opts?.itemLabel !== undefined && { item_label: opts.itemLabel }),
...(opts?.minItems !== undefined && { min_items: opts.minItems }),
...(opts?.maxItems !== undefined && { max_items: opts.maxItems }),
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
};
}
function mediaPicker(
actionId: string,
label: string,
opts?: {
mimeTypeFilter?: string;
initialValue?: string;
placeholder?: string;
},
): MediaPickerElement {
return {
type: "media_picker",
action_id: actionId,
label,
...(opts?.mimeTypeFilter !== undefined && { mime_type_filter: opts.mimeTypeFilter }),
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
};
}
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 }),
};
}
function tabBlock(
panels: TabPanel[],
opts?: {
defaultTab?: number;
blockId?: string;
},
): TabBlock {
return {
type: "tab",
panels,
...(opts?.defaultTab !== undefined && { default_tab: opts.defaultTab }),
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
};
}
function empty(opts: {
blockId?: string;
title: string;
description?: string;
commandLine?: string;
size?: "sm" | "base" | "lg";
actions?: Element[];
}): EmptyBlock {
return {
type: "empty",
title: opts.title,
...(opts.description !== undefined && { description: opts.description }),
...(opts.commandLine !== undefined && { command_line: opts.commandLine }),
...(opts.size !== undefined && { size: opts.size }),
...(opts.actions !== undefined && { actions: opts.actions }),
...(opts.blockId !== undefined && { block_id: opts.blockId }),
};
}
function accordion(opts: {
blockId?: string;
label: string;
blocks: Block[];
defaultOpen?: boolean;
}): AccordionBlock {
return {
type: "accordion",
label: opts.label,
blocks: opts.blocks,
...(opts.defaultOpen !== undefined && { default_open: opts.defaultOpen }),
...(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,
tab: tabBlock,
empty,
accordion,
};
export const elements = {
textInput,
numberInput,
select,
toggle,
button,
secretInput,
checkbox,
combobox,
dateInput,
radio,
repeater,
mediaPicker,
};

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,67 @@
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,
RepeaterElement,
RepeaterSubField,
MediaPickerElement,
Element,
// Form
FieldCondition,
FormField,
// Block sub-types
TableColumn,
StatItem,
ChartSeries,
ChartConfig,
TimeseriesChartConfig,
CustomChartConfig,
TabPanel,
// Blocks
HeaderBlock,
SectionBlock,
DividerBlock,
FieldsBlock,
TableBlock,
ActionsBlock,
StatsBlock,
FormBlock,
ImageBlock,
ContextBlock,
ColumnsBlock,
ChartBlock,
CodeBlock,
TabBlock,
BannerBlock,
MeterBlock,
EmptyBlock,
AccordionBlock,
Block,
// Interactions
BlockAction,
FormSubmit,
PageLoad,
BlockInteraction,
// Response
BlockResponse,
} from "./types.js";

View File

@@ -0,0 +1,64 @@
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} />;
case "repeater":
// Admin-authoring only. The runtime block renderer never returns a
// DOM node for `repeater` — values are persisted on the parent
// block and consumed by the plugin's own runtime component.
if ((import.meta as { env?: { DEV?: boolean } }).env?.DEV) {
console.warn(
"[blocks] renderElement: 'repeater' is an admin-authoring element and renders nothing at runtime",
);
}
return null;
case "media_picker":
return null;
default: {
const _exhaustive: never = element;
return null;
}
}
}

View File

@@ -0,0 +1,82 @@
import { AccordionBlockComponent } from "./blocks/accordion.js";
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 { EmptyBlockComponent } from "./blocks/empty.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 { TabBlockComponent } from "./blocks/tab.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} />;
case "tab":
return <TabBlockComponent block={block} onAction={onAction} />;
case "empty":
return <EmptyBlockComponent block={block} onAction={onAction} />;
case "accordion":
return <AccordionBlockComponent block={block} onAction={onAction} />;
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 @emdash-cms/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,415 @@
// ── 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;
}
/**
* Sub-field types allowed inside a RepeaterElement. Limited to the scalar
* inputs the admin widget currently renders inline.
*/
export type RepeaterSubField =
| TextInputElement
| NumberInputElement
| SelectElement
| ToggleElement;
/**
* Array-of-objects field. Renders as a list of collapsible cards with inline
* add/remove and drag-and-drop reordering. Sub-fields are scalar Block Kit
* elements keyed by their `action_id`.
*
* Admin-authoring only: this element is rendered by the admin widget so plugin
* blocks can capture repeating data. The runtime block renderer
* (`renderElement`) deliberately returns `null` for `repeater` — repeater
* values are persisted on the parent block and consumed by the plugin's own
* runtime component, not re-rendered as a stand-alone block.
*/
export interface RepeaterElement {
type: "repeater";
action_id: string;
label: string;
/** Singular label used in the UI (e.g. "FAQ" → "Add FAQ"). */
item_label?: string;
fields: RepeaterSubField[];
min_items?: number;
max_items?: number;
/**
* Default rows for the field. Note: the admin widget seeds new rows from
* the sub-field types (empty string / `false`), not from `initial_value`;
* plugins should populate persisted state via the form `values` payload
* instead of relying on `initial_value` for pre-filled rows.
*/
initial_value?: Array<Record<string, unknown>>;
}
/**
* Picks an item from the media library (or uploads a new one). The stored value
* is the selected asset's URL string, so this element is value-compatible with a
* plain `text_input` — existing content continues to work after swapping.
*/
export interface MediaPickerElement {
type: "media_picker";
action_id: string;
label: string;
/** Mime-type prefix filter (e.g. "image/"). Defaults to "image/". */
mime_type_filter?: string;
initial_value?: string;
placeholder?: string;
}
export type Element =
| ButtonElement
| TextInputElement
| NumberInputElement
| SelectElement
| ToggleElement
| SecretInputElement
| CheckboxElement
| DateInputElement
| ComboboxElement
| RadioElement
| RepeaterElement
| MediaPickerElement;
// ── 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 interface TabPanel {
label: string;
blocks: Block[];
}
export interface TabBlock extends BlockBase {
type: "tab";
panels: TabPanel[];
default_tab?: number;
}
export interface EmptyBlock extends BlockBase {
type: "empty";
title: string;
description?: string;
command_line?: string;
size?: "sm" | "base" | "lg";
actions?: Element[];
}
export interface AccordionBlock extends BlockBase {
type: "accordion";
label: string;
blocks: Block[];
default_open?: boolean;
}
export type Block =
| HeaderBlock
| SectionBlock
| DividerBlock
| FieldsBlock
| TableBlock
| ActionsBlock
| StatsBlock
| FormBlock
| ImageBlock
| ContextBlock
| ColumnsBlock
| ChartBlock
| BannerBlock
| MeterBlock
| CodeBlock
| TabBlock
| EmptyBlock
| AccordionBlock;
// ── 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