Making Plugins Useful
Introduction
'Read the “', Introduction, '” section'Building a StudioCMS Plugin is a powerful way to extend the functionality of StudioCMS. They provide a simple and flexible way to add new features to your StudioCMS project. The following is a basic example of how to create a StudioCMS Plugin and how it works.
Getting Started
'Read the “', Getting Started, '” section'To get started, you will need to create a new StudioCMS Plugin. The following is a basic example of the file structure for a StudioCMS Plugin:
- package.json
Directorysrc
- index.ts
Directoryroutes
- […slug].astro
Directorydashboard-grid-items
- MyPluginGridItem.astro
Creating the Plugin
'Read the “', Creating the Plugin, '” section'In the main src/index.ts
file, you will define the StudioCMS Plugin. The following is an example of how to define a StudioCMS Plugin that includes an Astro Integration to create a simple blog example:
import { function definePlugin(options: StudioCMSPluginOptions): StudioCMSPlugin
Defines a plugin for StudioCMS.
definePlugin } from 'studiocms/plugins';import { (alias) interface AstroIntegrationimport AstroIntegration
AstroIntegration } from 'astro';import { const addVirtualImports: HookUtility<"astro:config:setup", [{ name: string; imports: Imports; __enableCorePowerDoNotUseOrYouWillBeFired?: boolean;}], void>
Creates a Vite virtual module and updates the Astro config.
Virtual imports are useful for passing things like config options, or data computed within the integration.
addVirtualImports, const createResolver: (_base: string) => { resolve: (...path: Array<string>) => string;}
Allows resolving paths relatively to the integration folder easily. Call it like this:
createResolver } from 'astro-integration-kit';
// Define the options for the plugin and integrationinterface interface Options
Options { Options.route: string
route: string;}
export function function studioCMSPageInjector(options: Options): StudioCMSPlugin
studioCMSPageInjector(options: Options
options: interface Options
Options) {
// Resolve the path to the current file const { const resolve: (...path: Array<string>) => string
resolve } = function createResolver(_base: string): { resolve: (...path: Array<string>) => string;}
Allows resolving paths relatively to the integration folder easily. Call it like this:
createResolver(import.
The type of import.meta
.
If you need to declare that a given property exists on import.meta
,
this type may be augmented via interface merging.
meta.ImportMeta.url: string
The absolute file:
URL of the module.
url);
// Define the Astro integration function function (local function) myIntegration(options: Options): AstroIntegration
myIntegration(options: Options
options: interface Options
Options): (alias) interface AstroIntegrationimport AstroIntegration
AstroIntegration { const const route: string
route = `/${options: Options
options?.Options.route: string
route || 'my-plugin'}`;
return { AstroIntegration.name: string
The name of the integration.
name: 'my-astro-integration', AstroIntegration.hooks: { 'astro:config:setup'?: (options: { config: AstroConfig; command: "dev" | "build" | "preview" | "sync"; isRestart: boolean; updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig; addRenderer: (renderer: AstroRenderer) => void; addWatchFile: (path: URL | string) => void; injectScript: (stage: InjectedScriptStage, content: string) => void; injectRoute: (injectRoute: InjectedRoute) => void; addClientDirective: (directive: ClientDirectiveConfig) => void; addDevToolbarApp: (entrypoint: DevToolbarAppEntry) => void; addMiddleware: (mid: AstroIntegrationMiddleware) => void; createCodegenDir: () => URL; logger: AstroIntegrationLogger; }) => void | Promise<void>; ... 10 more ...; 'astro:routes:resolved'?: (options: { routes: IntegrationResolvedRoute[]; logger: AstroIntegrationLogger; }) => void | Promise<void>;} & Partial<...>
The different hooks available to extend.
hooks: { "astro:config:setup": (params: { config: AstroConfig; command: "dev" | "build" | "preview" | "sync"; isRestart: boolean; updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig; ... 8 more ...; logger: AstroIntegrationLogger;}
params) => { const { const injectRoute: (injectRoute: InjectedRoute) => void
injectRoute } = params: { config: AstroConfig; command: "dev" | "build" | "preview" | "sync"; isRestart: boolean; updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig; ... 8 more ...; logger: AstroIntegrationLogger;}
params;
// Inject the route for the plugin const injectRoute: (injectRoute: InjectedRoute) => void
injectRoute({ entrypoint: string | URL
entrypoint: const resolve: (...path: Array<string>) => string
resolve('./routes/[...slug].astro'), pattern: string
pattern: `/${const route: string
route}/[...slug]`, prerender?: boolean
prerender: false, })
function addVirtualImports(params: { config: AstroConfig; command: "dev" | "build" | "preview" | "sync"; isRestart: boolean; updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig; ... 8 more ...; logger: AstroIntegrationLogger;}, args_0: { ...;}): void
Creates a Vite virtual module and updates the Astro config.
Virtual imports are useful for passing things like config options, or data computed within the integration.
addVirtualImports(params: { config: AstroConfig; command: "dev" | "build" | "preview" | "sync"; isRestart: boolean; updateConfig: (newConfig: DeepPartial<AstroConfig>) => AstroConfig; ... 8 more ...; logger: AstroIntegrationLogger;}
params, { name: string
name: 'my-astro-integration', imports: Imports
imports: { 'myplugin:config': ` export const options = ${var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
stringify({ route: string
route })}; export default options; `, } }) } } } }
// Define the StudioCMS Plugin return function definePlugin(options: StudioCMSPluginOptions): StudioCMSPlugin
Defines a plugin for StudioCMS.
definePlugin({ identifier: string
identifier: 'my-plugin', name: string
name: 'My Plugin', studiocmsMinimumVersion: string
studiocmsMinimumVersion: '0.1.0-beta.8', integration?: AstroIntegration | AstroIntegration[] | undefined
integration: function (local function) myIntegration(options: Options): AstroIntegration
myIntegration(options: Options
options), // Optional, but recommended // Define the frontend navigation links for the plugin (optional) // This is useful if you are using the built in StudioCMS navigation helpers in your layout, // such as when using the `@studiocms/blog` Plugin. frontendNavigationLinks?: { label: string; href: string;}[] | undefined
frontendNavigationLinks: [{ label: string
label: 'Title here', href: string
href: options: Options
options?.Options.route: string
route || 'my-plugin' }], // When creating pageTypes, you can also define a `pageContentComponent` if your plugin requires a custom content editor. // pageTypes: [{ identifier: 'my-plugin', label: 'Blog Post (My Plugin)', pageContentComponent: resolve('./components/MyContentEditor.astro') }], // In this example we are okay using the default content editor (markdown). pageTypes?: { label: string; identifier: string; description?: string | undefined; fields?: ({ input: "checkbox"; label: string; name: string; required?: boolean | undefined; readOnly?: boolean | undefined; color?: "danger" | "success" | "warning" | "info" | "primary" | "mono" | undefined; defaultChecked?: boolean | undefined; size?: "sm" | "md" | "lg" | undefined; } | { input: "input"; label: string; name: string; type?: "number" | "search" | "email" | "tel" | "text" | "url" | "password" | undefined; required?: boolean | undefined; readOnly?: boolean | undefined; placeholder?: string | undefined; defaultValue?: string | undefined; } | { input: "textarea"; label: string; name: string; required?: boolean | undefined; readOnly?: boolean | undefined; placeholder?: string | undefined; defaultValue?: string | undefined; } | { input: "radio"; label: string; name: string; options: { label: string; value: string; disabled?: boolean | undefined; }[]; required?: boolean | undefined; readOnly?: boolean | undefined; color?: "danger" | "success" | "warning" | "info" | "primary" | "mono" | undefined; defaultValue?: string | undefined; direction?: "horizontal" | "vertical" | undefined; } | { input: "select"; label: string; name: string; options ...
pageTypes: [{ identifier: string
identifier: 'my-plugin', label: string
label: 'Blog Post (My Plugin)' }], // Define the grid items for the dashboard // These are the items that will be displayed on the StudioCMS Dashboard // You can define as many items as you want // In this example, we are defining a single item, which has a span of 2 and requires the 'editor' permission and injects an Astro component which replaces the plain html custom element. dashboardGridItems?: GridItemInput[] | undefined
dashboardGridItems: [ { GridItemInput.name: string
The name of the grid item.
name: 'example', GridItemInput.span: 1 | 2 | 3
The span of the grid item, which can be 1, 2, or 3.
span: 2, GridItemInput.variant: "default" | "filled"
The variant of the grid item, which can be 'default' or 'filled'.
variant: 'default', GridItemInput.requiresPermission?: "editor" | "owner" | "admin" | "visitor"
The required permission level to view the grid item.
Optional. Can be 'owner', 'admin', 'editor', or 'visitor'.
requiresPermission: 'editor', GridItemInput.header?: { title: string; icon?: HeroIconName;}
The header of the grid item.
Optional.
header: { title: string
The title of the header.
title: 'Example', icon?: "bolt" | "code-bracket-solid" | "code-bracket-square-solid" | "exclamation-circle" | "exclamation-circle-solid" | "exclamation-triangle" | "exclamation-triangle-solid" | ... 1280 more ... | "x-mark-solid"
The icon of the header.
Optional.
icon: 'bolt' }, GridItemInput.body?: { html: string; components?: Record<string, string>; sanitizeOpts?: SanitizeOptions;}
The body of the grid item.
Optional.
body: { // Always use plain html without `-` or special characters in the tags, they will get replaced with the Astro component and this HTML will never be rendered html: string
The HTML content of the body.
html: '<examplegriditem></examplegriditem>', components?: Record<string, string>
The components within the body.
Optional.
components: { // Inject the Astro component to replace the plain html custom element examplegriditem: string
examplegriditem: const resolve: (...path: Array<string>) => string
resolve('./dashboard-grid-items/MyPluginGridItem.astro') } } } ], });}
The above example defines a StudioCMS Plugin that includes an Astro Integration to create a simple blog example. The plugin includes a route that is injected into the StudioCMS project and a grid item that is displayed on the StudioCMS Dashboard.
Example route
'Read the “', Example route, '” section'In the src/routes/[...slug].astro
file, you will define the route for the plugin. The following is an example of how to define a route for the plugin, we will break this out into two parts, the first part is the frontmatter (between the ---
marks), and the second part is the HTML template that gets put under the second ---
.
import { const StudioCMSRenderer: any
StudioCMSRenderer } from 'studiocms:renderer';import const sdk: { addPageToFolderTree: (tree: FolderNode[], folderId: string, newPage: FolderNode) => FolderNode[]; ... 28 more ...; REST_API: { tokens: { get: (userId: string) => Promise<{ description: string | null; userId: string; id: string; key: string; creationDate: Date; }[]>; new: (userId: string, description: string) => Promise<{ description: string | null; userId: string; id: string; key: string; creationDate: Date; }>; delete: (userId: string, tokenId: string) => Promise<void>; verify: (key: string) => Promise<false | { userId: string; key: string; rank: string; }>; }; };}
sdk from 'studiocms:sdk';import const config: { route: string;}
config from 'myplugin:config';
const const makeRoute: (slug: string) => string
makeRoute = (slug: string
slug: string) => { return `/${const config: { route: string;}
config.route: string
route}/${slug: string
slug}`;}
// 'my-plugin' here is used as the identifier for// the pageType from the plugin definitionconst const pages: CombinedPageData[]
pages = await const sdk: { addPageToFolderTree: (tree: FolderNode[], folderId: string, newPage: FolderNode) => FolderNode[]; ... 28 more ...; REST_API: { tokens: { get: (userId: string) => Promise<{ description: string | null; userId: string; id: string; key: string; creationDate: Date; }[]>; new: (userId: string, description: string) => Promise<{ description: string | null; userId: string; id: string; key: string; creationDate: Date; }>; delete: (userId: string, tokenId: string) => Promise<void>; verify: (key: string) => Promise<false | { userId: string; key: string; rank: string; }>; }; };}
sdk.type GET: { database: { users: () => Promise<CombinedUserData[]>; pages: (includeDrafts?: boolean, tree?: FolderNode[]) => Promise<CombinedPageData[]>; config: () => Promise<{ id: number; title: string; description: string; defaultOgImage: string | null; ... 5 more ...; gridItems: unknown; } | undefined>; folders: () => Promise<{ ...; }[]>; }; databaseEntry: { users: { byId: (id: string) => Promise<CombinedUserData | undefined>; byUsername: (username: string) => Promise<CombinedUserData | undefined>; byEmail: (email: string) => Promise<CombinedUserData | undefined>; }; pages: { byId: (id: string, tree?: FolderNode[]) => Promise<CombinedPageData | undefined>; bySlug: (slug: string, tree?: FolderNode[]) => Promise<CombinedPageData | undefined>; }; folder: (id: string) => Promise<{ ...; } | undefined>; }; databaseTable: { users: () => Promise<{ name: string; username: string; email: string | null; id: string; url: string | null; avatar: string | null; password: string | null; updatedAt: Date | null; createdAt: Date | null; }[]>; oAuthAccounts: () => Promise<{ provider: string; providerUserId: string; userId: string; }[]>; sessionTable: () => ...
GET.packagePages: (packageName: string, tree?: FolderNode[]) => Promise<CombinedPageData[]>
packagePages('my-plugin');
const { const slug: string | undefined
slug } = const Astro: AstroGlobal<Record<string, any>, AstroComponentFactory, Record<string, string | undefined>>
Astro.AstroGlobal<Record<string, any>, AstroComponentFactory, Record<string, string | undefined>>.params: Record<string, string | undefined>
Parameters passed to a dynamic page generated using getStaticPaths
Example usage:
---export async function getStaticPaths() { return [ { params: { id: '1' } }, ];}
const { id } = Astro.params;---<h1>{id}</h1>
params;
const const page: CombinedPageData | undefined
page = const pages: CombinedPageData[]
pages.Array<CombinedPageData>.find(predicate: (value: CombinedPageData, index: number, obj: CombinedPageData[]) => unknown, thisArg?: any): CombinedPageData | undefined (+1 overload)
Returns the value of the first element in the array where predicate is true, and undefined
otherwise.
find((page: CombinedPageData
page) => page: CombinedPageData
page.slug: string
slug === const slug: string | undefined
slug || '');
{ slug && page ? ( <div> <h1>{page.title}</h1> <StudioCMSRenderer content={page.defaultContent?.content || ''} /> </div> ) : ( <div> <h1>My Plugin</h1> <ul> {pages.length > 0 && pages.map((page) => ( <li> <a href={makeRoute(page.slug)}>{page.title}</a> </li> ))} </ul> </div> )}
The above example defines a dynamic route^ for the plugin that displays a list of blog posts when no slug is provided and displays the content of a blog post when a slug is provided.
Example grid item
'Read the “', Example grid item, '” section'In the src/dashboard-grid-items/MyPluginGridItem.astro
file, you will define the grid item for the plugin. The following is an example of how to define a grid item for the plugin:
---import { StudioCMSRoutes } from 'studiocms:lib';import sdk from 'studiocms:sdk';
// 'my-plugin' here is used as the identifier for// the pageType from the plugin definitionconst pages = await sdk.GET.packagePages('my-plugin');
// Get the 5 most recently updated pages from the last 30 daysconst recentlyUpdatedPages = pages .filter((page) => { const now = new Date(); const thirtyDaysAgo = new Date(now.setDate(now.getDate() - 30)); return new Date(page.updatedAt) > thirtyDaysAgo; }) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) .slice(0, 5);---
<div> <h2>Recently Updated Pages</h2> <ul> {recentlyUpdatedPages.length > 0 && recentlyUpdatedPages.map((page) => ( <li> <a href={StudioCMSRoutes.mainLinks.contentManagementEdit + `?edit=${page.id}`}>{page.title}</a> </li> ))} </ul></div>
The above example defines a grid item for the plugin that displays the 5 most recently updated pages from the last 30 days. The grid item includes a list of links to the content management edit page for each page.
Integrating with the FrontendNavigationLinks helpers
'Read the “', Integrating with the FrontendNavigationLinks helpers, '” section'If you are looking to use the built-in StudioCMS navigation helpers in your project, similar to the way the @studiocms/blog
plugin does, you can create a custom Navigation.astro
component:
---import { StudioCMSRoutes } from 'studiocms:lib';import studioCMS_SDK from 'studiocms:sdk/cache';import { frontendNavigation } from 'studiocms:plugin-helpers';
// Define the props for the Navigation componentinterface Props { topLevelLinkCount?: number;};
// Get the top level link count from the propsconst { topLevelLinkCount = 3 } = Astro.props;
// Get the site config and page listconst config = (await studioCMS_SDK.GET.siteConfig()).data;
// Get the site title from the configconst { title } = config || { title: 'StudioCMS' };
// Get the main site URLconst { mainLinks: { baseSiteURL },} = StudioCMSRoutes;
// Define the link props for the navigationtype LinkProps = { text: string; href: string;};
// Define the links for the navigationconst links: LinkProps[] = await frontendNavigation();---{/* If no dropdown items */}{ ( links.length < topLevelLinkCount || links.length === topLevelLinkCount ) && ( <div class="navigation"> <div class="title"><a href={baseSiteURL}>{title}</a></div> <div class="mini-nav"> <button>Menu</button> <div class="mini-nav-content"> { links.map(({ text, href }) => ( <a {href}>{text}</a> )) } </div> </div> { links.map(({ text, href }) => ( <a class="links" {href}>{text}</a> )) } </div>) }
{/* If dropdown items */}{ links.length > topLevelLinkCount && ( <div class="navigation"> <div class="title"><a href={baseSiteURL}>{title}</a></div>
<div class="mini-nav"> <button>Menu</button> <div class="mini-nav-content"> { links.map(({ text, href }) => ( <a {href}>{text}</a> )) } </div> </div> { links.slice(0, topLevelLinkCount).map(({ text, href }) => ( <a class="links" {href}>{text}</a> )) } <div class="dropdown"> <button>More ▼</button> <div class="dropdown-content"> { links.slice(topLevelLinkCount).map(({ text, href }) => ( <a {href}>{text}</a> )) } </div> </div> </div>) }
The above example defines a custom Navigation.astro
component that uses the built-in StudioCMS navigation helpers to create a navigation menu for the project. The component includes links to the main site URL, the index page, and any other pages that are set to show on the navigation.
All you need to do is add some styles, and you have a fully functional navigation menu that works with the built-in StudioCMS navigation helpers.