@brilo/base-blocks
Shared Gutenberg block factory library. Tenants call factory functions, register the result, and own only
block.json + icon files. This file is the only doc — when it disagrees with src/blocks/*, the code wins.
Install (tenant)
Published as @brilo/base-blocks npm package — normal bun install in tenant pulls it.
For local dev against uncommitted base-blocks changes, use bun link:
# in base-blocks
bun run build
bun link
# in tenant gutenberg dir
bun link @brilo/base-blocks
Re-run bun run build in base-blocks after any change — link points at build/, not src/.
Commands
bun run build # biome check + tsc → build/
bun run check # biome check --write + tsc --noEmit
bun run format # biome (TS) + prettier (SCSS)
Structure
src/
blocks/
<domain>/ one per content type, mirrors frontoffice twig components 1:1:
anchor/, compare/, contact/, faq/, gallery/, hero/, person/,
signpost/, step/, testimonial/, usp/ … each with <domain>-group/
and/or <domain>-item/
core/ atomic, no inner blocks: basic-button · basic-heading · basic-overline
groups/ generic (non-domain) layout groups: banner-group · basic-header-group ·
button-group · content-group · text-and-media-group
sections/ top-level page sections: base-section
layout/ aside/content layout blocks
form/ form-group · form-field
link/ · map/ · newsletter/ · post/ · responsive-grid/
components/ IconControl · ImagePicker · ItemIconControl · PaletteColorControl
hooks/ useGroupBlock · useTenantColorPalette
utils/ createEditAdapter
constants.ts shared HEADING_LEVELS · HEADING_STYLES · CONTENT_SIZES
editor.scss SCSS barrel — @forward all block + component + global styles
globals.scss global editor utility classes (flex, gap-*, u-grid-*, …)
index.ts JS/TS barrel
types.ts shared types (BriloBlockType, SelectOption, Icon, OptionArray, …)
Don't invent new top-level folders: a block mirroring a frontoffice component goes in its domain folder, an atomic
one in core/, a generic wrapper with no content domain in groups/.
Suffix tells the job, same as frontoffice:
| suffix | job |
|---|---|
-group | InnerBlocks container. Layout/columns/direction controls. Restricts children via allowedBlocks. Never renders entity markup. |
-item | Single entity block inside a matching group (parent: ['brilo/xxx-group']). Never iterates. |
-section | Top-level page section, wraps groups (sections/base-section). |
-form / -field | Form container / single field. |
Block files
blocks/<domain>/<block-name>/
├── factory.tsx required
├── edit.tsx required
├── types.ts required
├── index.ts required
├── constants.ts only if the block has option arrays
├── icon.tsx only if the block needs a custom icon
└── editor.scss only if the block needs editor styles
| file | exports | rule |
|---|---|---|
factory.tsx | createXxxBlock(options) → BlockConfiguration<XxxAttributes> | Only place the WP attributes schema is defined. save: () => null (atomic/item) or () => <InnerBlocks.Content /> (group/section). Re-exports XxxAttributes/XxxOptions. |
edit.tsx | createXxxEdit(options) → React component | No inline arrays — pull from constants.ts. No module-level __(). |
types.ts | XxxAttributes, XxxOptions | XxxAttributes extends Record<string, unknown>, XxxOptions extends BriloBlockType. |
constants.ts | option arrays, DEFAULT_* | as const. Translated labels live here, not in edit.tsx. |
icon.tsx | icon (JSX <svg>) | Otherwise the factory uses a WP icon slug string (e.g. 'admin-links'). |
index.ts | barrel + xxxDefaults | xxxDefaults is defined here, not in factory.tsx. Constants not re-exported stay block-private. |
editor.scss | editor-only styles | @forward it from src/editor.scss. No frontend styles. |
Naming
| thing | pattern | example |
|---|---|---|
| factory | createXxxBlock | createBasicButtonBlock |
| edit factory | createXxxEdit | createBasicButtonEdit |
| attributes type | XxxAttributes | BasicButtonAttributes |
| options type | XxxOptions | BasicButtonOptions |
| defaults object | xxxDefaults | basicButtonDefaults |
Constants are SCREAMING_SNAKE. DEFAULT_ prefix only for a fallback of a tenant-overridable option; fixed sets
(HEADING_LEVELS, CHIP_POSITIONS) have no prefix.
Imports
tsconfig.json maps ~/* → ./src/*. Shared code is always ~/types, ~/constants, ~/hooks/...,
~/components/... — never ../../../. Block-local code is ./constants, ./types. Name conflict → import alias
(import {HEADING_LEVELS as SHARED_HEADING_LEVELS} from '~/constants').
Types
BriloBlockType (from ~/types) is the shared base of every XxxOptions: name, title?, description?,
category?, allowedBlocks?, template?, blockClassName?, containerClassName?, icon?, parent?.
SelectOption ({value, label}) also lives in ~/types — never redeclare it in a block.
Attribute unions are derived from as const arrays, never written by hand:
// constants.ts
export const MEDIA_POSITIONS = [
{value: 'left', label: __('Vlevo', 'brilo-blocks')},
{value: 'right', label: __('Vpravo', 'brilo-blocks')},
] as const
export const HEADING_LEVELS = [1, 2, 3] as const
// types.ts
export type MediaPosition = (typeof MEDIA_POSITIONS)[number]['value'] // 'left' | 'right'
export type HeadingLevel = (typeof HEADING_LEVELS)[number] // 1 | 2 | 3
Use the derived type for the attribute too (headingLevel: HeadingLevel, not number), so casts like
tagName={`h${headingLevel}` as `h${HeadingLevel}`} follow the constant. A block that allows only a subset of
heading levels defines its own HEADING_LEVELS; otherwise re-export the shared one from ~/constants.
Options pattern
Every tenant-overridable value has a DEFAULT_* in constants.ts, resolved once with ??. That resolved value —
never the raw default — feeds both the attribute enum and the edit UI, or tenant overrides get silently dropped:
// ✓ reflects tenant override
const variants = options.variants ?? DEFAULT_VARIANTS
attributes: {variant: {type: 'string', default: variants[0].value, enum: variants.map((v) => v.value)}}
// ✗ ignores tenant override
attributes: {variant: {enum: DEFAULT_VARIANTS.map((v) => v.value)}}
Mandatory options (like name) have no default and are required in the interface.
Generic factories
A factory whose options contain overridable enum arrays is generic, so TypeScript narrows attribute literals to
exactly what the tenant passed. Only overridable arrays become type params — fixed sets stay hardcoded.
usp/usp-group/factory.tsx is the fullest example.
// factory.tsx
export function createBasicButtonBlock<
TVariants extends OptionArray = typeof DEFAULT_VARIANTS,
TSizes extends OptionArray = typeof DEFAULT_SIZES,
>(options: BasicButtonOptions<TVariants, TSizes>): BlockConfiguration<BasicButtonAttributes<TVariants[number]['value'], TSizes[number]['value']>>
// edit.tsx stays non-generic; factory casts when calling it
edit: createBasicButtonEdit(options as unknown as BasicButtonOptions),
Constraint types from ~/types: OptionArray (ReadonlyArray<{value; label}>), NumberArray, StringArray.
Tenant side — as const is required for literal inference:
createBasicButtonBlock({...basicButtonDefaults}) // variant: 'primary' | 'secondary'
const MY_VARIANTS = [
{value: 'primary', label: 'Primární'},
{value: 'brand', label: 'Brand'},
] as const
createBasicButtonBlock({...basicButtonDefaults, variants: MY_VARIANTS}) // variant: 'primary' | 'brand'
Edit component
Group blocks use useGroupBlock (wraps useBlockProps + useInnerBlocksProps, accepts templateLock,
orientation, layout, renderAppender); item/atomic blocks use plain useBlockProps.
const {blockProps, innerBlocksProps} = useGroupBlock({
blockClassName: options.blockClassName ?? 'g-usp',
containerClassName: 'g-usp__list',
allowedBlocks: options.allowedBlocks,
template: options.template,
})
return (
<div {...blockProps}>
<div {...innerBlocksProps} />
</div>
)
Classes always via clsx, BEM, same root class as the twig component it renders into (g- group, i- item,
base- section) so editor.scss and theme SCSS agree:
const blockProps = useBlockProps({
className: clsx(options.blockClassName ?? 'i-button', `--variant-${variant}`, outline && '--outline'),
})
All user-visible strings: __('…', 'brilo-blocks').
Shared components and hooks
Use these instead of reimplementing the UI inside a block:
PaletteColorControl— labelled tenant-palette colour picker (label,help?,id?,value,onChange,enableAlpha?,clearable?). Takes and returns''for "no colour". Use for every colour attribute instead ofBaseControl+ColorPalette+useTenantColorPalette.IconControl— grid of preselected SVG icons.ItemIconControl+ItemIconPreview— icon control with the "preselected vs. media" toggle (anchor-item,usp-item).ImagePickerControl(sidebar upload/select) +ImagePickerBody(canvas preview),useMediaUrl(id).useTenantColorPalette— tenantpaletteColors+resolveHex(forContrastChecker).
New block checklist
- Pick domain folder + suffix (see Structure).
types.ts:XxxAttributes,XxxOptions.constants.tsif any option arrays.factory.tsx: resolve options with??, buildattributes, returnBlockConfiguration.edit.tsx:createXxxEdit(options), controls inInspectorControls→PanelBody.icon.tsxonly if needed.index.tsbarrel +xxxDefaults.editor.scss,@forwardfromsrc/editor.scss.- Export from
src/index.ts:export type {XxxAttributes, XxxOptions}+export {createXxxBlock, xxxDefaults}. bun run build, then register in the tenant (below).
Minimal templates:
// factory.tsx
import type {BlockConfiguration} from '@wordpress/blocks'
import {createXxxEdit} from './edit'
import type {XxxAttributes, XxxOptions} from './types'
export type {XxxAttributes, XxxOptions}
export function createXxxBlock(options: XxxOptions): BlockConfiguration<XxxAttributes> {
return {
name: options.name,
title: options.title ?? 'TODO',
description: options.description,
category: options.category ?? 'common',
...(options.parent && {parent: options.parent}),
supports: {html: false, customClassName: false},
attributes: {},
edit: createXxxEdit(options),
save: () => null,
} as unknown as BlockConfiguration<XxxAttributes>
}
// index.ts
export type {XxxAttributes, XxxOptions} from './types'
export {createXxxBlock} from './factory'
export {createXxxEdit} from './edit'
import type {XxxOptions} from './types'
export const xxxDefaults: XxxOptions = {
name: 'brilo/xxx',
parent: ['brilo/xxx-group'],
}
Tenant usage
import {createUspItemBlock, uspItemDefaults} from "@brilo/base-blocks"
import {registerBlockType} from "@wordpress/blocks"
import metadata from "./block.json"
import {ICONS, ICONS_SRC} from "./icons"
const config = createUspItemBlock({
...uspItemDefaults,
name: metadata.name,
parent: metadata.parent as string[],
icons: ICONS,
iconsSrc: ICONS_SRC,
})
registerBlockType(config.name as string, config)
Every tenant block dir needs a block.json next to index.tsx: build metadata ($schema, apiVersion, name,
editorScript, editorStyle) plus title, description, parent/allowedBlocks, attributes (with enum for
option sets) and an example, mirrored from the factory. The block catalog reads them via
/wp/v2/block-types/brilo.
Blocks that support it take extraAttributes (tenant attributes on top of base ones) and extraControls (extra
Inspector controls below the base ones); createBaseSectionBlock also takes exclude to drop base attributes:
type ExtraAttrs = {overlap: boolean}
const config = createBaseSectionBlock<ExtraAttrs>({
name: "my-tenant/base-section",
allowedBlocks: ["my-tenant/basic-header-group", "my-tenant/usp-group"],
blockClassName: "s-base",
containerClassName: "s-base__inner",
extraAttributes: {overlap: {type: "boolean", default: false}},
extraControls: ({attributes, setAttributes}) => (
<ToggleControl
label={__("Překrytí sekce", "brilo-blocks")}
checked={attributes.overlap}
onChange={(value) => setAttributes({overlap: value})}
/>
),
// exclude: ["background"],
})
Styles (tenant)
In tenant index.scss:
@use "@brilo/base-blocks/src/editor"; /* block styles, mostly in @layer base */
@import "@brilo/gutenberg-toolkit/build/index.css";
@layer base, tenant; /* tenant layer always wins */
Override base styles in tenant SCSS with @layer tenant { } — works only for files whose styles sit in
@layer base. Some block editor.scss files are still unlayered: they rely on beating unlayered WP editor
defaults (h*/p/hr margins and font sizes, .wp-block margins) and on overriding .i-item-icon. Layered CSS
loses to any unlayered CSS, so override those unlayered too. Moving them into the layer needs a per-file check in the
editor.