Blocks & attributes

Describe editable block data

Attribute specs are how Uncial derives the editing UI you'd otherwise hand-build: each spec maps a component prop to a typed, validated control in the block attributes panel.

  • default — Required fallback value. Shorthand values like title: "" become defaults.

  • input — Editor control hint: text, textarea, number, checkbox, json, richtext, select, image, or hidden. Left out, it is guessed from the default value.

  • required — Marks an attribute as required during validation.

  • validate — Custom predicate for accepting or rejecting an attribute value.

  • parse / serialize — Coerce values at editor or persistence boundaries.

  • options — Allowed values for select-style attributes.

Let authors add images

Give an attribute input: 'image' and the attributes panel shows an image field for it. The attribute stores a plain URL string.

// src/lib/blocks/figure.ts
import { defineSvelteBlock } from 'uncial';
import Figure from './Figure.svelte';

export const figure = defineSvelteBlock({
	id: 'figure',
	label: 'Figure',
	attributes: {
		src: { default: '', input: 'image' },
		alt: ''
	},
	component: Figure
});

Where images come from is up to you. Pass an imageSource to Editor with any of these three functions, and the field shows a button for each one you provide:

  • upload(file) — save the file somewhere and return the URL to store. Adds an Upload button.

  • browse() — return the URLs of images that already exist. Adds a Choose existing button that opens a picker.

  • thumbnail(src) — turn a stored URL into one the browser can show. Use it when the stored value is not directly displayable, such as an ID or a path without a base.

<script lang="ts">
	import { Editor, type ImageSource } from 'uncial';
	import { blocks, schema, attributesController } from './uncial';

	const imageSource: ImageSource = {
		async upload(file) {
			const body = new FormData();
			body.append('file', file);
			const response = await fetch('/api/images', { method: 'POST', body });
			return (await response.json()).url;
		},
		async browse() {
			return (await fetch('/api/images')).json();
		}
	};

	let document = $state({ type: 'doc', content: [{ type: 'paragraph' }] });
</script>

<Editor {blocks} {schema} {attributesController} {imageSource} bind:json={document} />

Right after an upload, the editor shows the image from a local blob: copy, so it appears at once even if your server is slow to serve it. The document still stores the URL that upload returned.

Allow nested document flow

Nesting is where form-driven admins fall apart — and where in-place editing pays off most. Editors write nested content inside the rendered block, exactly where it will appear, not in a collapsed sub-form three scroll-screens away.

// src/lib/blocks/collapsible.ts
import { defineSvelteBlock } from 'uncial';
import Collapsible from './Collapsible.svelte';

export const collapsible = defineSvelteBlock({
	id: 'collapsible',
	label: 'Collapsible',
	attributes: {
		title: ''
	},
	component: Collapsible,
	content: { kind: 'flow' }
});
<!-- src/lib/blocks/Collapsible.svelte -->
<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		title?: string;
		children?: Snippet;
	}

	let { title = '', children }: Props = $props();
</script>

<details class="collapse collapse-arrow border border-base-300 bg-base-100">
	<summary class="collapse-title font-semibold">{title}</summary>
	<div class="collapse-content">
		{#if children}
			{@render children()}
		{/if}
	</div>
</details>

Svelte ships first, core stays neutral

Svelte is the only bundled block runtime today. Non-Svelte block authoring is enabled through public runtime plugins that normalize native components and can provide editor node-view mounting. A registry may contain blocks from only one runtime in this release; mixed-runtime documents fail fast.

Svelte blocks keep the direct Svelte renderer path, so custom block rendering remains SSR-capable. Future React or Vue support should be implemented with full-document runtime renderers that can use each framework's SSR APIs, not by mixing client-only component islands into the Svelte renderer.

import { defineRuntimeBlock } from 'uncial/core';
import { reactRuntime } from 'uncial-react-runtime';

export function defineReactBlock(config) {
	return defineRuntimeBlock(reactRuntime, config);
}