npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-utils

Peer 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:

  • type is the component's human name (from the island's opts.name, i.e. the component.yml name: — the machine name is not in the markup), and
  • props holds 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.
  • CanvasSlotReactNode alias; a slot arrives as React children in most runtime contexts and as an HTML string in server-rendered / editor paths, both of which satisfy ReactNode. Reach for getSlotChildren (@freelygive/canvas-utils/slots) when a component needs the string form as structured metadata.
  • CanvasMenuItem{ id, title?, url?, parent?, weight?, _children?, _hasSubmenu? } — the tree shape sortMenu (from drupal-canvas) produces on top of Drupal's jsonapi_menu_items payload. Only id is guaranteed; consumers reading title should 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 and window.drupalSettings?.canvasData?.v0.pageTitle type-checks without a unknown cast.
  • React.CSSProperties — accepts CSS custom properties inline (style={{ '--foo': 'red' }}) with no as cast.

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 (default src/types/canvas-components.d.ts).
  • --components <path> — override the components directory. Defaults to the same precedence Canvas CLI uses: componentDir in canvas.config.jsonCANVAS_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? })) — set emitTypes: true (or { out }) to emit the canvas-utils-gen-types declaration file at plugin load and again on component changes, skipping the separate CLI invocation. Registration order matters: Storybook's own tsconfig-paths integration runs before user enforce: 'pre' plugins, so a bare @/components/foo reaches the resolver as <componentsDir>/foo. The resolver matches BOTH the alias form AND the alias-rewritten absolute form, and consumers must still unshift it before vite-tsconfig-paths so it wins for the bare alias on non-Storybook builds too.
  • componentCssPlugin(componentsDir?) — auto-imports a component's sibling <name>.css for 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.tsx play-function tests picked up by a stories glob aren't rejected at indexing time. Pass it directly as experimental_indexers.

Resolution order for componentsDir. Both plugins accept componentsDir as an optional argument; when omitted, they resolve it the same way Canvas CLI does:

  1. Explicit argument (if truthy).
  2. componentDir field in canvas.config.json at process.cwd().
  3. CANVAS_COMPONENT_DIR env var — legacy, emits a one-time deprecation warning. Prefer canvas.config.json.
  4. Default "src/components" relative to process.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.tsx

Assertions 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 just innerHTML). 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's component.yml name: (surfaced as the parsed item's type via opts.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.