How blocks work
A block turns a fenced section of YAML in a document into something rendered: a priced table, a diagram, a form. This page covers what a block is, where Plicine looks for one, and how it decides which one wins when more than one could match. For the exact file format, see The .block package.
What a block is
A packaged block is a .block file, a zip containing manifest.json, BLOCK.md, package.json, an optional bun.lock, and a src/ folder. Its entry module exports three things:
export const schema: z.ZodType; // props validation, and the source of the edit form
export function Interactive(props): JSX.Element;
export function Print(props): JSX.Element; // static and deterministic
manifest.json gives the block a stable id (@scope/name), a short alias used as the fence name in documents, a semver version, and the capabilities it's allowed to ask for (network access, saved storage). Writing a block walks through building one.
Core, package and inline blocks
Plicine distinguishes three kinds of block:
- Core blocks ship with the app itself:
page-break,toc,children,image,callout,cardandcolumns. Each has the id@core/<name>as well as its short name, both reserved; they're source in the app, compiled with it, rendered by the app rather than in a sandbox (there's no untrusted code to isolate), and no approval prompt is needed for them.ts-block(@core/ts-block) is also reserved, but it's an inline block, below. Core blocks lists them. - Package blocks come from a
.blockfile, resolved from the vault, a library or the community folder as described below. They're compiled once per exact file content and run inside a sandboxed frame, only after someone approves them (see Why a block asks before it runs). - Inline blocks use the
ts-blockfence name to hold TypeScript written directly in the document, rather than packaged as a.blockfile. They go through the same compile step and the same approval prompt as a package block, keyed by the hash of that exact code, but have no manifest of their own.
Resolution order
When a fence name needs resolving to an actual block, Plicine looks in three places, in this order: the vault's own .vault/blocks/ folder, then the library folder in app data, then the community folder in app data. The first source that has a matching block wins; a vault-local copy always beats an installed one with the same name.
Aliases and pins
A fence's name is normally matched against a block's own alias from its manifest. Two things can override that:
-
vault.jsonaliases map a short name used in documents to a full block id, optionally with a version range, for the whole vault:"aliases": { "quote": "@solunify/quote-table@^2" }. -
Frontmatter pins do the same thing for one document only, under a
blocks:key:blocks: quote-table: "@solunify/quote-table@^2"
A pin or alias takes an id with an optional semver range (@^2, @1.4.0, or no range at all for "any version"). Without either, Plicine just looks for an installed block whose own alias matches the fence name. An alias or pin can also point at a core block, "contents": "@core/toc"; the @core scope is reserved, so no package can ever be the target of one of those.
Collisions and conflicted copies
If more than one installed block claims the same name, Plicine still renders something rather than failing, and reports what it did as a warning:
- If several different blocks in the winning source claim the same alias, one is picked (deterministically) and the others are named in a warning.
- If a block is shadowed by another source with the same name, the winning one is used and the shadowed one is named in a warning.
- If two files resolve to the very same id and version but have different content, "conflicting copies", the shorter file name wins (a sync client's "conflicted copy" suffix is rarely the shortest name), and both files are named in the warning so you know to clean one up.
None of this stops the document rendering; it just tells you when something needs attention.
The lock prefers the locked hash
blocks.lock records, for each name used in the vault, exactly which id, version and content hash last resolved. As long as a candidate with that exact hash is still installed somewhere the resolver would otherwise look, it keeps using it, even if a newer version has since appeared alongside it, the same way npm ci prefers a lockfile over a fresher node_modules. Dropping a newer .block file in next to the old one doesn't silently change what renders; removing the old file does, and the resolver then falls back to the best remaining candidate and says so in a warning.
The file itself is JSON, one entry for each name the vault's documents use:
{
"lockfileVersion": 1,
"blocks": {
"quote-table": {
"id": "@solunify/quote-table",
"version": "2.1.0",
"hash": "229eec57928d645039cbdf96010711b39f8d2347b5e0e4feb08cdc0d9750494f",
"source": "vault"
}
}
}
A key is the bare fence name, or name=spec when a document's frontmatter pins one ("quote-table=@solunify/quote-table@^3"), so a pinned document gets an entry of its own. Only package blocks are locked; core blocks and ts-block have no installed version to pin. Entries are sorted by key, so the file reads the same whatever order the documents were scanned in, and it's rewritten only when its text would change. plicine lock recomputes it by hand, and plicine lock --frozen reports drift without writing, which is the check for CI (CLI reference).
Interactive vs Print
Every block renders twice, in two different modes, from the same schema-validated props:
Interactiveis what you see in the app's infinite layout: it can use state, effects, anything React normally allows, and re-renders live as you edit its props.Printis what's used for the paginated layout and PDF export: it has to be static and deterministic, rendered once withrenderToStaticMarkupand given no chance to run effects or read anything not in its props.plicine validatechecks this on a block folder by renderingPrinttwice with identical data and warning if the two renders differ.
Width
Any block can be wider or narrower than the text column. The width goes at the end of the fence's first line, ```callout {width="wide"}, or {width="full"} or {width="narrow"}, and is set from the Narrow, Normal, Wide and Full control on the block's toolbar or in its panel. It belongs to the app rather than the block: it isn't a prop, a block's schema never mentions it, and a package block's frame simply follows the width it's given. A markdown table, image or paragraph gets the same attribute in a <!-- {width="wide"} --> comment on the line above it. Wide and full mean different things on screen and on paper; Core blocks has the details.
Forms from schemas
A block's schema is also how the app builds an edit form for it, without the block author writing any form code. Plicine converts the schema to JSON Schema and renders a form from that shape: objects become groups of fields, arrays of scalar objects become tables, arrays of objects become cards, and so on. Editing a form patches only the lines of the document's YAML that actually changed. If a schema can't be converted to JSON Schema, the block can still be edited as markdown instead (or by hand in Source); the app just can't build a form for it. Writing a block covers what makes a schema form well.