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

@allejo/decap-extras

v0.0.5

Published

A TypeScript utility library for Decap CMS with utility types to derive types from Decap CMS configuration files.

Readme

@allejo/decap-extras

[!WARNING]

This is an experimental package in early development. I'm currently dog feeding this project in my own projects and am constantly improving the API as I discover better patterns.

See my nextjs-decap-typescript-template repository for a real-world example of how to use this package in a Next.js project.

A TypeScript utility library for Decap CMS that does two things:

  1. Widget factory functions — type-safe helpers for building Decap CMS field configuration objects instead of writing raw object literals by hand.
  2. Type inference utilities — TypeScript utility types that derive content types directly from your CMS config, so you never have to maintain separate type definitions for your CMS content.

Installation

npm install @allejo/decap-extras
# or
pnpm add @allejo/decap-extras
# or
yarn add @allejo/decap-extras

This package requires decap-cms-core as a peer dependency:

npm install decap-cms-core

Usage

Widget factories

All factory functions follow the same signature: widget(label, name, options?).

import {
	BARE_MARKDOWN,
	boolWidget,
	imageWidget,
	INLINE_MARKDOWN,
	listWidget,
	markdownWidget,
	numberWidget,
	objectWidget,
	optional,
	selectWidget,
	stringWidget,
	textWidget,
} from '@allejo/decap-extras';

Scalar widgets

const titleField = stringWidget('Title', 'title');
const bodyField = textWidget('Body', 'body');
const countField = numberWidget('Count', 'count');
const activeField = boolWidget('Active', 'active');

Select widget

Pass the choices array as const to get a narrowed literal union type; omit as const to get string.

const themeField = selectWidget('Theme', 'theme', ['light', 'dark'] as const);
// TypeScript type: "light" | "dark"

const looseField = selectWidget('Size', 'size', ['sm', 'md', 'lg']);
// TypeScript type: string

Object widget

const metaField = objectWidget('Meta', 'meta', [
	stringWidget('Title', 'title'),
	stringWidget('Description', 'description'),
]);

List widget

Pass a single field to get Array<T>, or an array of fields to get Array<{ ... }>.

// Single-field list → Array<string>
const tagsField = listWidget('Tags', 'tags', stringWidget('Tag', 'tag'));

// Multi-field list → Array<{ label: string; value: string }>
const tagObjectsField = listWidget('Tag Objects', 'tagObjects', [
	stringWidget('Label', 'label'),
	stringWidget('Value', 'value'),
]);

Image widget

To pass a default transformation (supported by services like Cloudinary), use the opts parameter to pass a media_library configuration.

const heroField = imageWidget('Hero Image', 'hero');

Markdown widget

// Full markdown editor
const contentField = markdownWidget('Content', 'content');

// Restricted to bold, italic, lists, links, and quotes — no images or code blocks
const descriptionField = markdownWidget(
	'Description',
	'description',
	BARE_MARKDOWN,
);

// Inline elements only: bold, italic, and links
const captionField = markdownWidget('Caption', 'caption', INLINE_MARKDOWN);

Optional fields

Wrap any widget with optional() to mark it as not required in both the Decap CMS UI and the derived TypeScript type.

const subtitleField = optional(stringWidget('Subtitle', 'subtitle'));
// TypeScript type: string | undefined

Common options

Every factory accepts an optional last argument for common widget options:

const slugField = stringWidget('Slug', 'slug', {
	required: true,
	hint: 'URL-friendly identifier, e.g. my-page-title',
	pattern: ['^[a-z0-9-]+$', 'Lowercase letters, numbers, and hyphens only'],
});

Type inference

Define your CMS config using the widget factories and as const, then let PropsByCollectionAndFile derive a fully typed content object from it automatically.

// cms/config.ts
import {
	BARE_MARKDOWN,
	boolWidget,
	imageWidget,
	markdownWidget,
	objectWidget,
	optional,
	selectWidget,
	stringWidget,
} from '@allejo/decap-extras';

export const config = {
	collections: [
		{
			label: 'Pages',
			name: 'pages' as const,
			files: [
				{
					label: 'Home',
					name: 'home' as const,
					file: 'content/home.yml',
					fields: [
						stringWidget('Title', 'title'),
						optional(stringWidget('Subtitle', 'subtitle')),
						imageWidget('Hero Image', 'hero'),
						markdownWidget('Body', 'body', BARE_MARKDOWN),
						selectWidget('Theme', 'theme', ['light', 'dark'] as const),
						objectWidget('SEO', 'seo', [
							stringWidget('Meta Title', 'metaTitle'),
							optional(stringWidget('Meta Description', 'metaDescription')),
						]),
					],
				},
			],
		},
	],
} as const;
// components/HomePage.ts
import type { PropsByCollectionAndFile } from '@allejo/decap-extras';

import type { config } from '@/cms/config';

type HomePageProps = PropsByCollectionAndFile<typeof config, 'pages', 'home'>;
// Resolves to:
// {
//   title: string;
//   subtitle?: string;
//   hero: string;
//   body: string;
//   theme: "light" | "dark";
//   seo: {
//     metaTitle: string;
//     metaDescription?: string;
//   };
// }

function render(page: HomePageProps) {
	console.log(page.title); // string
	console.log(page.subtitle); // string | undefined
	console.log(page.theme); // "light" | "dark"
	console.log(page.seo.metaTitle); // string
}

Typing reusable widget functions

Use WidgetTypeFromFactory to derive a TypeScript type from a widget factory function without duplicating the definition.

import type { WidgetTypeFromFactory } from '@allejo/decap-extras';
import { objectWidget, optional, stringWidget } from '@allejo/decap-extras';

function seoWidget() {
	return objectWidget('SEO', 'seo', [
		stringWidget('Meta Title', 'metaTitle'),
		optional(stringWidget('Meta Description', 'metaDescription')),
	]);
}

type SeoWidget = WidgetTypeFromFactory<typeof seoWidget>;
// { metaTitle: string; metaDescription?: string }

Constraining to valid file names

Use CollectionItemNames to get a union of all file name literals for a collection. This is useful for constraining generic components or functions that accept a page name.

import type { CollectionItemNames } from '@allejo/decap-extras';

import type { config } from '@/cms/config';

type PageName = CollectionItemNames<typeof config, 'pages'>;
// "home" | "about" | ...

function getPageProps<F extends PageName>(
	page: F,
): PropsByCollectionAndFile<typeof config, 'pages', F> {
	// ...
}

React Integration

Decap CMS is distributed either as a single .js bundle you can load in an HTML file with <script> or as an ESM module you can import. In order to integrate type-safety into working with Decap, we want to incorporate Decap CMS as a Next.js page so that we can get all the type-safety from working within it as a framework. There's one small problem with this though...

You can also use Decap CMS as an npm module. Wherever you import Decap CMS, it automatically runs, taking over the current page.

This means, we can't just import Decap CMS in a Next.js page and call it a day. This is because at build time, Next.js will run the code in the page to generate the static HTML, and if Decap CMS is imported and initialized at that time, it's going to try to take over the page during the build process, which is not what we want. We only want Decap CMS to take over the page when it's running in the browser.

To work around this, we can use dynamic imports (i.e., import()) inside a useEffect hook to initialize Decap CMS only on the client after the Next.js boilerplate has rendered and allow the CMS instance to take over the page.

useEffect(() => {
	Promise.all([
		import('decap-cms-app'),

		// You can import other Decap CMS modules
		// import('decap-cms-media-library-cloudinary')
	]).then(([cmsMod /*, cloudinaryMod*/]) => {
		const cms = cmsMod.default;
		const cloudinary = cloudinaryMod.default;

		cms.init({ config });

		// ...
		// cms.registerPreviewStyle(...)
		// cms.registerPreviewTemplate(...);
		// cms.registerWidget(...);
		// cms.registerMediaLibrary(cloudinary);
	});
}, []);

The import() syntax, commonly called dynamic import, is a function-like expression that allows loading an ECMAScript module asynchronously and dynamically into a potentially non-module environment.

Is there a more elegant solution to this problem? I'm not sure! If you can think of one, I would gladly welcome the idea.

But knowing all of this and setting it up gets tedious and boilerplate-y really fast. So, in this package, we export a React component called DecapInstance that abstracts away all of this dynamic importing and initialization logic, so you can just use it as a wrapper around your admin page and pass in your config, preview styles, templates, widgets, and Cloudinary flag as props.

The <DecapInstance /> Component

We provide a React component called DecapInstance that handles the dynamic import and initialization of Decap CMS for you.

import type { PropsByCollectionAndFile } from '@allejo/decap-extras';
import { getCssFilesFromManifest } from '@allejo/decap-extras/next';
import { DecapInstance } from '@allejo/decap-extras/react';
import type { ComponentType } from 'react';

import type { cmsConfig } from '@/cms/config';

// The HomePageProps type and HomePreview component are normally imported from
// other files, but are included here inline for demonstration purposes.
type HomePageProps = PropsByCollectionAndFile<
	typeof cmsConfig,
	'pages',
	'home'
>;
function HomePreview(props: HomePageProps) {
	return (
		<article>
			<h1>{props.title}</h1>
			{props.subtitle ? <p>{props.subtitle}</p> : null}
		</article>
	);
}

type AdminProps = {
	cssFiles: string[];
};

export async function getStaticProps() {
	const css = getCssFilesFromManifest(process.env.NODE_ENV ?? 'development', [
		'/',
		'/_app',
	]);

	return {
		props: {
			cssFiles: css.flattenedCssFiles,
		},
	};
}

export default function Admin({ cssFiles }: AdminProps) {
	return (
		<DecapInstance
			config={cmsConfig}
			cssFiles={cssFiles}
			// Pass explicitly so media-library behavior is visible in admin setup.
			useCloudinary={true}
			pages={{
				home: HomePreview as unknown as ComponentType<never>,
			}}
			widgets={{}}
			onInit={(cms) => {
				console.log('Decap initialized', cms);
			}}
		/>
	);
}

How it Works

getCssFilesFromManifest reads Next.js's .next/build-manifest.json at build time and returns all CSS files associated with the / and /_app routes. These are passed as props to the admin page so that we can register the CSS files into Decap's preview functionality.

export async function getStaticProps() {
	const cssFiles = getCssFilesFromManifest(process.env.NODE_ENV, [
		'/',
		'/_app',
	]);
	return { props: { cssFiles: cssFiles['flattenedCssFiles'] } };
}

[!WARNING]

This is a bit of a hacky solution to get the CSS files and therefore has some known issues.

The CSS for a page might not exist during local development because Next.js only generates the build manifest as the pages are requested. Therefore, if launch next dev and immediately open the admin page, the CSS files won't be found for the homepage (i.e., /) and won't be registered in the CMS preview, resulting in an unstyled preview. To work around this, you can either visit the homepage first to trigger the generation of the CSS files in the manifest, or run next build to generate the manifest with all CSS files before running next dev. During local development, this function will check the dev manifest at .next/dev/build-manifest.json first, and fallback to the regular manifest at .next/build-manifest.json if the dev manifest doesn't exist, so it should work in either case as long as the CSS files have been recorded.

If you have a better idea of handling this, please reach out!

Initialization sequence inside useEffect

cms.init({ config });

Bootstraps the CMS with the config from src/cms/config.ts.

cssFiles.forEach((css) => cms.registerPreviewStyle(css));

Registers every collected CSS file with the CMS preview panel so the editor renders with production styles.

[!WARNING]

FIXME: There HAS to be a better way of handling this so that we only load the relevant stylesheets for specific previews. I'm not sure how to solve this yet, but I'll come back to it.

cms.registerPreviewTemplate(pageId, ({ entry }) => {
  return <Component {...entry.get('data').toJSON()} />;
});

Maps each CMS page entry (home, etc.) to its real Next.js page component, passing the editor's live data as props. Editors see the actual production component rendering their changes in real time.

cms.registerMediaLibrary(cloudinary);

Registers the Cloudinary media picker.

[!WARNING]

Being able to disable Cloudinary is a work-in-progress. I've always needed it for the projects I'm building so it's enabled by default. I'll get to respecting the "disable" environment variable... eventually.

Next.js utilities

A server-side utility for getting CSS from Next.js build manifests. This is useful for registering CSS stylesheets into Decap's CMS preview system.

import { getCssFilesFromManifest } from '@allejo/decap-extras/next';

Use getCssFilesFromManifest in getStaticProps to collect the CSS paths needed by your page and pass them as props:

export async function getStaticProps() {
	const cssFiles = getCssFilesFromManifest(process.env.NODE_ENV, [
		'/',
		'/_app',
	]);

	return {
		props: {
			cssFiles: cssFiles['flattenedCssFiles'],
		},
	};
}

Then register each path as a preview stylesheet in your Decap CMS admin component:

export default function Admin({ cssFiles }: Props) {
	// ...

	cssFiles.forEach((css) => {
		cms.registerPreviewStyle(css);
	});
}

API reference

Widget functions

| Function | Decap widget | TypeScript type | | -------------------------------------------- | -------------------------------------------------------------- | ------------------------- | | stringWidget(label, name, opts?) | string | string | | textWidget(label, name, opts?) | text | string | | markdownWidget(label, name, opts?) | richtext | string | | imageWidget(label, name, opts?) | image | string | | numberWidget(label, name, opts?) | number | number | | boolWidget(label, name, opts?) | boolean | boolean | | selectWidget(label, name, choices, opts?) | select | literal union or string | | listWidget(label, name, field, opts?) | list | Array<T> | | listWidget(label, name, fields[], opts?) | list | Array<{ ... }> | | objectWidget(label, name, fields[], opts?) | object | { ... } | | optional(widget) | — | marks field optional |

Markdown presets

| Constant | Description | | ----------------- | ------------------------------------------------------------- | | BARE_MARKDOWN | Bold, italic, lists, links, quotes. No images or code blocks. | | INLINE_MARKDOWN | Bold, italic, and links only. No block-level elements. |

Type utilities

| Type | Description | | ----------------------------------------- | ------------------------------------------------------------------------------------ | | PropsByCollectionAndFile<TConfig, C, F> | Derives a typed content object from a CMS config for collection C and file F. | | CollectionItemNames<TConfig, C> | Union of all file name literals defined in collection C. | | WidgetTypeFromFactory<F> | Returns the TypeScript content type produced by a widget factory function F. | | OptionalWidget<T> | Brands a CmsField as optional (added by optional()). | | WidgetOpts<T> | The options type for a given widget field type, with widget and fields stripped. |

Server utilities

| Function | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | getCssFilesFromManifest(env, pages) | Reads the Next.js build manifest and returns CSS paths for the given page routes, grouped by page (allCssFiles) and as a deduplicated flat list (flattenedCssFiles). |

React utilities

| Type/Function | Description | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | DecapInstance | React component that initializes Decap CMS, registers preview CSS/templates/widgets, optionally enables Cloudinary, and returns null after setup. | | DecapInstanceProps | Props contract for DecapInstance, including config, preview CSS paths, page template registry, widget registry, and optional onInit/useCloudinary. |

License

MIT