Blockleaf

Code blocks

Syntax highlighting, shipped as an opt-in subpath rather than in the default bundle.

Syntax highlighting ships as an opt-in subpath, not in the default bundle.

import { createExtensions } from '@blockleaf/editor/extensions';
import { withCodeBlock } from '@blockleaf/editor/code-block';

const extensions = withCodeBlock(createExtensions('standard'));
<BlockleafEditor extensions={extensions} />

Why it is a subpath

lowlight and its grammars cost 16.53 kB. The default bundle had 15.37 kB left under its 160 kB ceiling, so shipping highlighting by default put the editor at 161.16 kB โ€” over the line, before the renderer or any of the remaining nodes were built.

Most documents contain no code at all. Charging every consumer for a feature most of them will not use is how a 400 kB editor happens, one justified 16 kB at a time. So importing from @blockleaf/editor/code-block is what adds the weight, and importing nothing adds nothing. The measurement is recorded as F14 in open-flags.md; the same shape was chosen for @blockleaf/editor/validate in F5.

Without the subpath you still get code blocks โ€” StarterKit's plain one, with ```input rules andMod+Alt+C. You get them without colours. The stored document is byte-identical either way, because CodeBlockNodehas carried an optionallanguage since the v0.1 schema. Adding or removing the subpath migrates nothing.

withCodeBlock versus createCodeBlock

Both the plain and the highlighted extension are named codeBlock, and two Tiptap extensions sharing a name is a warning and an undefined winner. withCodeBlock filters the plain one out before adding the highlighted one, which makes the replacement explicit:

withCodeBlock(createExtensions('standard'));
// โ‰ก [...extensions without codeBlock, createCodeBlock()]

Use createCodeBlock() directly only when you are assembling an extension array that never had a code block in it.

For a non-React consumer, createCodeBlockExtension() is the same thing without the language picker โ€” src/core cannot import React, so the picker is assembled a layer up.

The grammar set

Seven languages ship by default:

bash ยท css ยท javascript ยท json ยท python ยท typescript ยท xml

xml covers HTML; highlight.js has no separate HTML grammar.

Seven rather than lowlight's common set of thirty-seven, because common costs 39.27 kB against 16.53 kB curated. Syntax highlighting is not worth more of the budget than the rest of the editor.

Registering more

import rust from 'highlight.js/lib/languages/rust';
import { withCodeBlock } from '@blockleaf/editor/code-block';

withCodeBlock(createExtensions('standard'), {
	additionalLanguages: { rust }
});

additionalLanguages adds to the curated set. languages replaces it entirely, for hosts that want three languages rather than ten.

Whatever is registered is what the picker offers โ€” a language in the dropdown that no grammar backs would be a promise the editor cannot keep.

The language picker

A dropdown in the block's top-right corner, visible on hover or focus, labelled Code language for screen readers and disabled in a read-only document. Choosing Plain text sets language to null.

Turn it off with createCodeBlock({ picker: false }) โ€” you keep the highlighting and lose the dropdown.

The picker is a React node view, which means the code block renders through React rather than through renderHTML. If you are reaching for the DOM in a test, assert on editor.getJSON() instead; the class names come from the node view, not the schema.

An unknown language does not break the document

A document saved with language: "rust" by a host that registered Rust will be opened by hosts that did not.

When that happens the block renders unhighlighted, and keeps its language attribute. It does not throw, and the attribute is not stripped โ€” stripping it would quietly destroy the author's choice on the next save, which is worse than showing plain text.

This needs saying because the upstream extension does throw: it guards the unregistered case against highlight.js's module-level registry rather than the instance's, and those are two different registries. The throw happens while the plugin builds its first decorations, which is during editor construction โ€” so the failure mode is a blank screen, not a dull code block. createCodeBlock wraps the lowlight instance to close that hole.

On this page