@freelygive/canvas-utils
v0.7.0
Published
Runtime utilities and test helpers for Drupal Canvas code components.
Readme
@freelygive/canvas-utils
Runtime utilities and test helpers for Drupal Canvas projects.
These are plain functions, hooks and one small component exported from the package. Import them directly from the package in your code and stories.
Installation
npm install @freelygive/canvas-utilsPeer dependencies (install the ones you use): react, and for useMainEntity,
drupal-canvas, drupal-jsonapi-params and swr.
Runtime utilities
Each module is its own entry point — import from the specific subpath:
| Import from | Export | Kind | Description |
| ------------------------------------- | ------------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------- |
| @freelygive/canvas-utils/slots | getSlotChildren(slot) | function | Parse a Canvas slot into an array of items you can read and rebuild (see below). |
| @freelygive/canvas-utils/ssr | useHydrated() | hook | false during SSR and the first client render, then true after mount — for progressive enhancement (see below). |
| @freelygive/canvas-utils/editor | isCanvasEditorMode() | function | true when rendering inside the Canvas page editor (i.e. when window.drupalSettings.canvas is set). false under SSR and on Canvas's anonymous /canvas/preview/... route. |
| @freelygive/canvas-utils/editor | EditorNote | component | Renders its children in a pre-styled dashed-border note box. Gate it behind isCanvasEditorMode() to show editor-only guidance. Styling is inline (SSR-safe, no Tailwind scan / CSS setup required) — themeable via --canvas-editor-note-* custom properties. |
| @freelygive/canvas-utils/editor | useEditorFullHeight(enabled) | hook | { minHeight } for full-height sections that min-h-screen breaks in the editor iframe; else undefined. |
| @freelygive/canvas-utils/navigation | toSlug(name) | function | URL-friendly slug from a string (used for hash navigation). |
| @freelygive/canvas-utils/navigation | getIndexFromHash(items, getName) | function | Index of the item whose toSlug(getName(item)) matches the current URL hash, or -1. |
| @freelygive/canvas-utils/navigation | useHashNav({ items, getName }) | hook | [activeIndex, setHashIndex], kept in sync with the URL hash via replaceState (no scroll jump). |
| @freelygive/canvas-utils/entity | useMainEntity(type, options?) | hook | SWR fetch of the current page's main entity via JSON:API. |
| @freelygive/canvas-utils/types | CanvasImage, CanvasVideo, CanvasSlot, CanvasMenuItem | types | Shared TypeScript shapes for Canvas image/video field props, slot props, and sortMenu items — one declaration everyone imports from. |
| canvas-utils-gen-types (CLI) | Ambient .d.ts for @/components/<name> | bin | Generates a TypeScript declaration file from <componentsDir>/<folder>/<name>.component.yml — one block per component. |
import {
EditorNote,
isCanvasEditorMode,
} from '@freelygive/canvas-utils/editor';
import { useMainEntity } from '@freelygive/canvas-utils/entity';
import { toSlug, useHashNav } from '@freelygive/canvas-utils/navigation';
import { getSlotChildren } from '@freelygive/canvas-utils/slots';
import { useHydrated } from '@freelygive/canvas-utils/ssr';
import type {
CanvasImage,
CanvasMenuItem,
CanvasSlot,
CanvasVideo,
} from '@freelygive/canvas-utils/types';getSlotChildren(slot) — reading and rebuilding slot children
At runtime Canvas delivers a slot as <canvas-island> markup, not as React
children. getSlotChildren returns one entry per top-level island as
{ type, props }, where:
typeis the component's human name (from the island'sopts.name, i.e. thecomponent.ymlname:— the machine name is not in the markup), andpropsholds the component's own props plus each of its slots as a Canvas slot element ({ name, value }— the same shape Canvas delivers a slot in), keyed by slot name.
When the slot is plain React children instead (e.g. Storybook), it returns the
same { type, props } items (read from each element's type and props). So a
parent that splits/controls slot children reads metadata from the items and
rebuilds each with <Child {...item.props} />, which enters the child component
exactly as if composed directly. In the Canvas editor the slot must be output
directly (not rebuilt) so the editor can manage the slotted children:
'use client';
import { isCanvasEditorMode } from '@freelygive/canvas-utils/editor';
import { toSlug, useHashNav } from '@freelygive/canvas-utils/navigation';
import { getSlotChildren } from '@freelygive/canvas-utils/slots';
import TabItem from './tab-item';
const getTitle = (item) => item?.props?.title ?? '';
const Tabs = ({ content }) => {
const editor = isCanvasEditorMode();
const items = getSlotChildren(content);
// Switching is disabled in the editor, so there's no active tab to track.
const [active, setActive] = useHashNav({
items: editor ? [] : items,
getName: getTitle,
});
return (
<>
<nav>
{items.map((item, i) =>
// Editor: inert labels. Live: buttons that switch the active tab.
editor ? (
<span key={i}>{getTitle(item)}</span>
) : (
<button
key={i}
aria-selected={i === active}
onClick={() => setActive(i)}
>
{getTitle(item)}
</button>
),
)}
</nav>
{/* Editor: output the slot directly so Canvas manages every child.
Live: render only the active reconstructed panel. */}
{editor
? content
: items.map((item, i) => (
<div key={i} id={toSlug(getTitle(item))} hidden={i !== active}>
<TabItem {...item.props} />
</div>
))}
</>
);
};Because each slot comes back as a renderable element, a child just outputs its slot prop like any component — no string handling, the same code path whether it was composed directly or reconstructed from an island:
const TabItem = ({ content }) => <div className="tabs-panel">{content}</div>;Under Canvas SSR, both the parent (which reads and rebuilds the slot) and
each slot-child component must start with 'use client';. A child only appears
in the slot markup as a <canvas-island> carrying its props — which is what
getSlotChildren reads — when it is a client component; a server-rendered child
is inlined as plain HTML with no props to extract.
useHydrated() — progressive enhancement
For a component that renders static markup on the server (or with no JavaScript)
and upgrades to interactive behaviour once mounted. It returns false during
SSR and on the first client render — so that render matches the server and
there is no hydration mismatch — then true after mount. Branch on it (or a
class derived from it) rather than swapping the DOM structure. A component using
it must start with 'use client';, or Canvas leaves it as the static server
render and it never hydrates.
'use client';
import { useHydrated } from '@freelygive/canvas-utils/ssr';
import { cn } from 'drupal-canvas';
const Disclosure = ({ summary, content }) => {
const interactive = useHydrated();
return (
<div className={cn('disclosure', interactive && 'is-interactive')}>
<button type="button">{summary}</button>
<div className="disclosure-body">{content}</div>
</div>
);
};EditorNote — themable dashed callout
Styles are applied inline so EditorNote renders identically in every
consumer regardless of the project's Tailwind scan config, and stays SSR-safe
(no CSS side-effect import is issued at load time). The dashed-border,
cream-tint look is provided by defaults; override any of them by setting the
CSS custom properties on any ancestor:
:root {
--canvas-editor-note-bg: rgba(0, 0, 0, 0.05);
--canvas-editor-note-border: rgba(59, 130, 246, 0.5);
--canvas-editor-note-color: rgba(0, 0, 0, 0.75);
}A caller-supplied className still applies alongside the inline styles.
useEditorFullHeight(enabled)
The editor renders components in an iframe whose height is stretched to fit
content, which breaks min-h-screen / h-screen. This hook returns
{ minHeight } set to 80% of the real viewport (or undefined outside the
editor, letting CSS handle it).
import {
isCanvasEditorMode,
useEditorFullHeight,
} from '@freelygive/canvas-utils/editor';
const Hero = () => {
const editorStyle = useEditorFullHeight(isCanvasEditorMode());
return (
<div className="min-h-screen" style={editorStyle}>
…
</div>
);
};useMainEntity(type, options?)
options is { includes?: string[], fields?: Record<string, string[]> } —
includes maps to JSON:API include, and fields to sparse fieldsets
(fields[type]). Returns the SWR result ({ data, isLoading, error, … }).
import { useMainEntity } from '@freelygive/canvas-utils/entity';
const { data, isLoading } = useMainEntity('node--article', {
fields: { 'node--article': ['title'] },
});Shared types — @freelygive/canvas-utils/types
Canvas's JSON-schema field types (image, video, slots) show up as
identical-shape props on almost every component. Import the shared TypeScript
declarations instead of redeclaring them locally.
import type {
CanvasImage,
CanvasSlot,
CanvasVideo,
} from '@freelygive/canvas-utils/types';
type HeroProps = {
title: string;
image?: CanvasImage;
video?: CanvasVideo;
content?: CanvasSlot;
};CanvasImage—{ src: string; alt?: string; width?: number; height?: number }— mirrors$ref: json-schema-definitions://canvas.module/image.CanvasVideo—{ src: string; poster?: string }— mirrors$ref: json-schema-definitions://canvas.module/video.CanvasSlot—ReactNodealias; a slot arrives as React children in most runtime contexts and as an HTML string in server-rendered / editor paths, both of which satisfyReactNode. Reach forgetSlotChildren(@freelygive/canvas-utils/slots) when a component needs the string form as structured metadata.CanvasMenuItem—{ id, title?, url?, parent?, weight?, _children?, _hasSubmenu? }— the tree shapesortMenu(fromdrupal-canvas) produces on top of Drupal'sjsonapi_menu_itemspayload. Onlyidis guaranteed; consumers readingtitleshould still branch on presence for the linkset-menu variant.
Ambient globals — @freelygive/canvas-utils/globals
A side-effect import that widens two ambient shapes every Canvas project ends up needing:
Window.drupalSettings— carries Canvas's page/site payloads (canvasData.v0) and the editor sentinel (canvas). Import it once andwindow.drupalSettings?.canvasData?.v0.pageTitletype-checks without aunknowncast.React.CSSProperties— accepts CSS custom properties inline (style={{ '--foo': 'red' }}) with noascast.
Import once (in a top-level entry file, or via tsconfig types):
import '@freelygive/canvas-utils/globals';// tsconfig.json
{
"compilerOptions": {
"types": ["@freelygive/canvas-utils/globals"]
}
}Types-only — nothing is bundled at runtime.
Generating TypeScript declarations — canvas-utils-gen-types
@/components/<name> isn't a real file path — the Canvas CLI's named-metadata
layout is <componentsDir>/<folder>/<name>.component.yml + <name>.<ext>, and
Vite/Storybook resolve the alias at build time via componentNameResolver. For
TypeScript's own type-checker to see through the alias you need an ambient
declare module '@/components/<name>' block per component. canvas-utils-gen-types
generates them for you by scanning the same layout the resolver uses.
npx canvas-utils-gen-types [--components <dir>] [--out <file>]--out <path>— output file (defaultsrc/types/canvas-components.d.ts).--components <path>— override the components directory. Defaults to the same precedence Canvas CLI uses:componentDirincanvas.config.json→CANVAS_COMPONENT_DIR(deprecated) →src/components.
Each component in <componentsDir> gets one block. The generator parses the
entry file with the TypeScript compiler API and emits an explicit named
re-export for every top-level export:
declare module '@/components/<name>' {
export { buttonVariants } from '<relative-path-to-entry>';
export type { ButtonSize, ButtonVariant } from '<relative-path-to-entry>';
export { default } from '<relative-path-to-entry>';
}Named enumeration is required because export * from '…' inside an ambient
declare module block does not propagate named type/value exports under
moduleResolution: bundler — so the alias would type-check for import Foo
but blow up on import { FooProps }. By listing the exports individually the
alias mirrors the entry file's public surface exactly, with no per-component
upkeep. Handled shapes: export const/let/var, export function,
export class, export enum, export type / export interface, mixed
export { A, type B } groups, export type { … } from '…', aliases
(export { A as B }), and export default. export * from '<other>' in the
entry file is intentionally not recursed into — keep the entry's public API
inside the entry file. Wire the CLI as a prebuild script (or run it whenever
.component.yml files change) so the generated file stays in sync. Commit the
generated file so editors and CI both see the type identity.
If you're already using componentNameResolver (below), you can skip the
separate CLI invocation and pass emitTypes: true (or emitTypes: { out: '…' })
to the plugin — it writes the same file at plugin load and again on component
file changes.
Vite + Storybook plugins — @freelygive/canvas-utils/vite-storybook
Vite plugins and a Storybook indexer helper for Canvas projects that follow the
CLI's named-metadata component layout (<folder>/<name>.component.yml +
<name>.<ext>). Consumers plug these into .storybook/main.ts and
vite.config.js. The package stays framework-agnostic and dependency-light
(Node fs / path and the Plugin / Indexer types only).
// .storybook/main.ts
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
componentCssPlugin,
componentNameResolver,
testsIndexer,
} from '@freelygive/canvas-utils/vite-storybook';
import { mergeConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import type { StorybookConfig } from '@storybook/react-vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default {
// …
async viteFinal(config) {
// MUST come before vite-tsconfig-paths — see below.
config.plugins = config.plugins || [];
config.plugins.unshift(componentNameResolver());
config.plugins.push(tsconfigPaths({ root: path.resolve(__dirname, '..') }));
return mergeConfig(config, {
plugins: [componentCssPlugin()],
});
},
experimental_indexers: testsIndexer,
} satisfies StorybookConfig;componentNameResolver(componentsDir?)— resolves@/components/<name>to<folder>/<name>.<ext>(via the sibling.component.yml), whether<folder>equals<name>or not. Also accepts an options object (componentNameResolver({ componentsDir?, emitTypes? })) — setemitTypes: true(or{ out }) to emit thecanvas-utils-gen-typesdeclaration file at plugin load and again on component changes, skipping the separate CLI invocation. Registration order matters: Storybook's owntsconfig-pathsintegration runs before userenforce: 'pre'plugins, so a bare@/components/fooreaches the resolver as<componentsDir>/foo. The resolver matches BOTH the alias form AND the alias-rewritten absolute form, and consumers must stillunshiftit beforevite-tsconfig-pathsso it wins for the bare alias on non-Storybook builds too.componentCssPlugin(componentsDir?)— auto-imports a component's sibling<name>.cssfor Storybook (Canvas does this itself on the live site). Handles both the single-component (index.jsx+index.css) and multi-component (<name>.jsx+<name>.css) layouts. Stories / tests (.stories.*,.tests.*) are skipped.testsIndexer(existingIndexers)— registers the built-in CSF indexer for.tests.*files too, so colocated<machineName>.tests.tsxplay-function tests picked up by a stories glob aren't rejected at indexing time. Pass it directly asexperimental_indexers.
Resolution order for componentsDir. Both plugins accept componentsDir as
an optional argument; when omitted, they resolve it the same way Canvas CLI
does:
- Explicit argument (if truthy).
componentDirfield incanvas.config.jsonatprocess.cwd().CANVAS_COMPONENT_DIRenv var — legacy, emits a one-time deprecation warning. Prefercanvas.config.json.- Default
"src/components"relative toprocess.cwd().
Building Canvas tests
Tests are Storybook stories with a play function, kept under
src/stories/tests/ with autodocs disabled (tags: ['!autodocs']). They run in
a real browser via @storybook/addon-vitest:
npm run test # all stories
npm run test -- src/stories/tests/tabs.stories.tsxAssertions use expect, within, userEvent from storybook/test. Use the
test helpers below to feed components realistic Canvas input and to simulate the
editor.
Hydration caveat: a
<canvas-island>only renders once Canvas's runtime hydrates it, which Storybook doesn't load — so islands output directly (e.g. a slot rendered as-is in editor mode) stay inert: the custom elements sit in the DOM but their component bodies never render. Plain HTML inside a slot, though, renders normally (it is justinnerHTML). So when a parent parses islands and rebuilds children whose slot bodies are plain-HTML fixtures, that content does render and you can assert on it; reach for plain React children only when you need a child component to actually mount (e.g. to drive interaction on it).
import {
createCanvasIsland,
createCanvasSlot,
} from '@freelygive/canvas-utils/testing/canvas-slots';
import { withEditorMode } from '@freelygive/canvas-utils/testing/editor-mode';
import { expect, userEvent, within } from 'storybook/test';
import type { Meta, StoryObj } from '@storybook/react-vite';
import Tabs from './tabs';
const meta = {
title: 'Tests/Tabs',
component: Tabs,
tags: ['!autodocs'],
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;Simulating Canvas slots — testing/canvas-slots
createCanvasIsland({ props, name, slots }) builds the <canvas-island> markup
Canvas emits for one slotted component, and createCanvasSlot(html, name?)
wraps a string of islands into the Canvas slot element ({ name, value }, the
same shape Canvas delivers a slot in) that getSlotChildren parses — name
(default "content") mirrors the component's slot prop:
props— the component's own props (encoded as the["raw", value]tuples Canvas uses;rawProp(value)is exported if you need a tuple by hand).name— the component'scomponent.ymlname:(surfaced as the parsed item'stypeviaopts.name).slots— a map of slot name → inner HTML, emitted as<template data-astro-template="…">, so the child is fully reconstructable.
const slot = createCanvasSlot(
createCanvasIsland({
props: { title: 'First tab' },
name: 'Tab Item',
slots: { content: '<p>First panel body.</p>' },
}) +
createCanvasIsland({
props: { title: 'Second tab' },
name: 'Tab Item',
slots: { content: '<p>Second panel body.</p>' },
}),
);
export const FromCanvasIsland: Story = {
args: { content: slot },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getAllByRole('tab')).toHaveLength(2);
},
};Simulating editor mode — testing/editor-mode
withEditorMode is a decorator that turns on editor mode before the first
render and cleans up on unmount (so state is restored even if a test is
interrupted). enableEditorMode() / disableEditorMode() are exported for
direct control.
export const EditorRender: Story = {
decorators: [withEditorMode],
args: { content: slot },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByText(/editor/i)).toBeVisible();
},
};If a slot-parsing component is involved, add a test that simulates the parse
path with createCanvasIsland (the format that would otherwise only be
exercised in production).
Stubbing JSON:API — testing/jsonapi-client
JsonApiClientTestPatch types the subset of
@drupal-api-client/json-api-client's JsonApiClient.prototype that stories
monkey-patch to stub JSON:API responses without a live Drupal backend. Cast
via as unknown as JsonApiClientTestPatch rather than any:
import type { JsonApiClientTestPatch } from '@freelygive/canvas-utils/testing/jsonapi-client';
export const Default: Story = {
beforeEach: async () => {
const { JsonApiClient } = await import(
'@drupal-api-client/json-api-client'
);
const client = JsonApiClient.prototype as unknown as JsonApiClientTestPatch;
client.getCollection = async (type) =>
type === 'node--news_article'
? [{ id: 'uuid-1', publish_date: '2020-03-12' }]
: [];
},
args: { heading: 'Example' },
};Types-only at runtime — no shipped code, no dependency on
@drupal-api-client/json-api-client.
