@allejo/decap-extras
v0.0.10
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:
- Widget factory functions — type-safe helpers for building Decap CMS field configuration objects instead of writing raw object literals by hand.
- 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-extrasThis package requires decap-cms-core as a peer dependency:
npm install decap-cms-coreUsage
Widget factories
All factory functions follow a similar signature of widget(label, name, options?).
import {
boolWidget,
imageWidget,
INLINE_MARKDOWN,
listWidget,
markdownWidget,
numberWidget,
objectWidget,
optional,
RICH_MARKDOWN,
selectWidget,
stringWidget,
textWidget,
} from '@allejo/decap-extras';Widget modifiers
optional(widget)
Marks a widget field as optional, allowing it to be left empty in DecapCMS. This modifier can be applied to any widget type to make it non-required.
TypeScript:
optional(stringWidget('Middle Name', 'middleName'));YAML Configuration:
- label: Middle Name
name: middleName
widget: string
required: false # All fields are required by default, so this is necessary to make it optionalData Store YAML:
middleName: # Can be left emptyThe optional modifier can be used with all widget types including complex ones:
optional(
objectWidget('Metadata', 'metadata', [stringWidget('Author', 'author')]),
);
optional(listWidget('Tags', 'tags', stringWidget('Tag', 'value')));The optional modifier affects both the Decap CMS UI (field is not required) and the derived TypeScript type (field becomes T | undefined).
Learn more: DecapCMS Common Widget Options
Primitive widgets
stringWidget(label, name, opts?)
Creates a single-line text input field for short text content.
TypeScript:
stringWidget('Title', 'title');YAML Configuration:
- label: Title
name: title
widget: stringData Store YAML:
title: Welcome to My BlogUnlike the textWidget which provides multi-line input, the string widget is designed for concise, single-line values like titles, slugs, or names.
optional(stringWidget('Slug', 'slug'));See also: textWidget, markdownWidget
Learn more: DecapCMS String Widget Docs
textWidget(label, name, opts?)
Creates a multi-line text input field for longer text content.
TypeScript:
textWidget('Description', 'description');YAML Configuration:
- label: Description
name: description
widget: textData Store YAML:
description: |-
This is a longer description that spans
multiple lines in the data store.Unlike the stringWidget which provides single-line input, the text widget is designed for paragraphs and multi-line content.
optional(textWidget('Notes', 'notes'));See also: stringWidget, markdownWidget
Learn more: DecapCMS Text Widget Docs
numberWidget(label, name, opts?)
Creates a numeric input field with optional min, max, and step constraints.
TypeScript:
numberWidget('Priority', 'priority');YAML Configuration:
- label: Priority
name: priority
widget: numberData Store YAML:
priority: 1The number widget provides numeric input capabilities:
optional(numberWidget('Views', 'views'));Learn more: DecapCMS Number Widget Docs
boolWidget(label, name, opts?)
Creates a checkbox input field for boolean values.
TypeScript:
boolWidget('Published', 'published');YAML Configuration:
- label: Published
name: published
widget: booleanData Store YAML:
published: trueThe boolean widget creates a checkbox input in the editor:
optional(boolWidget('Draft', 'isDraft'));Learn more: DecapCMS Boolean Widget Docs
selectWidget(label, name, choices, opts?)
Creates a dropdown menu with predefined options.
TypeScript:
selectWidget('Status', 'status', ['draft', 'published', 'archived'] as const);YAML Configuration:
- label: Status
name: status
widget: select
options:
- draft
- published
- archivedData Store YAML:
status: publishedPass 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: stringThe select widget can be made optional:
optional(
selectWidget('Category', 'category', [
'tech',
'lifestyle',
'business',
] as const),
);Learn more: DecapCMS Select Widget Docs
imageWidget(label, name, opts?)
Creates a file upload field with image preview capabilities.
TypeScript:
imageWidget('Featured Image', 'featuredImage');YAML Configuration:
- label: Featured Image
name: featuredImage
widget: imageData Store YAML:
featuredImage: /images/hero.jpgThe image widget provides file upload functionality with image preview. To pass a default transformation (supported by services like Cloudinary), use the opts parameter to pass a media_library configuration:
imageWidget('Hero Image', 'hero');
optional(imageWidget('Avatar', 'avatar'));Learn more: DecapCMS Image Widget Docs
Complex widgets
objectWidget(label, name, fields, opts?)
Groups multiple fields together as a nested object in the data store.
TypeScript:
objectWidget('Author', 'author', [
stringWidget('Name', 'name'),
stringWidget('Email', 'email'),
]);YAML Configuration:
- label: Author
name: author
widget: object
fields:
- label: Name
name: name
widget: string
- label: Email
name: email
widget: stringData Store YAML:
author:
name: Jane Doe
email: [email protected]This is useful for creating structured data with related properties. Objects can be nested within lists or other objects to create complex data structures:
optional(
objectWidget('Metadata', 'metadata', [
stringWidget('SEO Title', 'seoTitle'),
textWidget('Description', 'description'),
]),
);Learn more: DecapCMS Object Widget Docs
listWidget(label, name, field/fields, opts?)
Creates a list widget that can contain either single-field items or multi-field items.
Single-field list:
TypeScript:
listWidget('Tags', 'tags', stringWidget('Tag', 'value'));YAML Configuration:
- label: Tags
name: tags
widget: list
field:
label: Tag
name: value
widget: stringData Store YAML:
tags:
- value: React
- value: TypeScriptMulti-field list:
TypeScript:
listWidget('Authors', 'authors', [
stringWidget('Name', 'name'),
imageWidget('Avatar', 'avatar'),
]);YAML Configuration:
- label: Authors
name: authors
widget: list
fields:
- label: Name
name: name
widget: string
- label: Avatar
name: avatar
widget: imageData Store YAML:
authors:
- name: Jane Doe
avatar: /images/jane.jpg
- name: John Smith
avatar: /images/john.jpg[!WARNING]
When passing an Object widget (e.g.,
objectWidget(...)), this function will unwrap the object and returnArray<{ ... }>, notArray<T>. This is done as a convenience so you don't have an extra layer of nesting in your data structure. For example, by unwrapping the object, you can have a list of items like this:listOfItems: - label: 'Item 1' value: 'Value 1'If the object was not unwrapped, the data structure would look like this:
listOfItems: - item: label: 'Item 1' value: 'Value 1'If you do want the extra nesting, simply wrap the object in an array when passing it to
listWidget(), like this:const nestedField = listWidget('Nested Items', 'nestedItems', [ objectWidget('Item', 'item', [ stringWidget('Label', 'label'), stringWidget('Value', 'value'), ]), ]);
The list widget can be made optional:
optional(listWidget('Tags', 'tags', stringWidget('Tag', 'value')));Learn more: DecapCMS List Widget Docs
Markdown widget
markdownWidget(label, name, opts?)
Creates a rich text editor for markdown content.
TypeScript:
// Full markdown editor
markdownWidget('Content', 'content');
// Restricted to bold, italic, lists, links, and quotes — no images or code blocks
markdownWidget('Description', 'description', RICH_MARKDOWN);
// Inline elements only: bold, italic, and links
markdownWidget('Caption', 'caption', INLINE_MARKDOWN);YAML Configuration:
- label: Content
name: content
widget: richtextData Store YAML:
content: |
# Heading
This is **bold** and *italic* text with [links](https://example.com).Learn more: DecapCMS RichText Widget Docs
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 {
boolWidget,
imageWidget,
markdownWidget,
objectWidget,
optional,
RICH_MARKDOWN,
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', RICH_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 devand 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 runnext buildto generate the manifest with all CSS files before runningnext dev. During local development, this function will check the dev manifest at.next/dev/build-manifest.jsonfirst, and fallback to the regular manifest at.next/build-manifest.jsonif 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 API reference
Widget modifiers
| Function | Description |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| optional(widget) | Marks a widget field as optional (non-required). Learn more |
Primitive widgets
| Function | Decap widget | TypeScript type |
| ------------------------------------------- | ------------------------------------------------------- | ------------------------- |
| stringWidget(label, name, opts?) | string | string |
| textWidget(label, name, opts?) | text | string |
| numberWidget(label, name, opts?) | number | number |
| boolWidget(label, name, opts?) | boolean | boolean |
| selectWidget(label, name, choices, opts?) | select | literal union or string |
| imageWidget(label, name, opts?) | image | string |
Complex widgets
| Function | Decap widget | TypeScript type |
| -------------------------------------------- | ----------------------------------------------------- | ---------------- |
| listWidget(label, name, field, opts?) | list | Array<T> |
| listWidget(label, name, fields[], opts?) | list | Array<{ ... }> |
| objectWidget(label, name, fields[], opts?) | object | { ... } |
Rich text widgets
| Function | Decap widget | TypeScript type |
| ------------------------------------ | -------------------------------------------------------------- | --------------- |
| markdownWidget(label, name, opts?) | richtext | string |
Markdown presets
| Constant | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| RICH_MARKDOWN | Stripped down markdown editor with bold, italic, lists (bullet + numbered), quotes, and links. No images or code blocks. Rich Text preview only. |
| INLINE_MARKDOWN | Stripped down markdown widget that allows only inline elements (bold, italic, and links). No block-level elements like lists or quotes. Rich Text preview only. |
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
