plugins
# Plugin Development
Extend MarkoPress with custom plugins.
# Overview
MarkoPress plugins let you hook into the build process and extend functionality. The plugin system provides:
- Content Processing - Transform and organize content
- Markdown Extensions - Add custom markdown syntax and features
- Route Generation - Create custom routes and endpoints
- Build Hooks - Run code before/after builds
- Config Transformation - Modify build configuration
# Plugin Structure
A plugin is an object that implements the MarkoPressPlugin interface:
import type { MarkoPressPlugin } from 'markopress/plugin';
const myPlugin: MarkoPressPlugin = {
name: 'my-plugin',
// Hook: Transform config
config(config) {
return config;
},
// Hook: Process content
contentLoaded(ctx) {
// Access and transform content
},
// Hook: Before build
beforeBuild(ctx) {
// Prepare for build
},
// Hook: After build
afterBuild(ctx) {
// Post-process build output
},
// Hook: Extend markdown
extendMarkdown(md) {
// Add markdown-it plugins
},
// Hook: Extend routes
extendRoutes(routes) {
// Add custom routes
return routes;
},
};
# Quick Start
# Your First Plugin
Create a plugin that logs all pages:
// plugins/logger.ts
import type { MarkoPressPlugin, ContentContext } from 'markopress/plugin';
export default function loggerPlugin(): MarkoPressPlugin {
return {
name: 'logger-plugin',
contentLoaded(ctx: ContentContext) {
const pages = ctx.getPages();
const posts = ctx.getPosts();
ctx.utils.log('=== Content Loaded ===');
ctx.utils.log(`Pages: ${pages.length}`);
ctx.utils.log(`Posts: ${posts.length}`);
for (const page of pages) {
ctx.utils.log(` - ${page.routePath}`);
}
},
};
}
Use it in your config:
// markopress.config.ts
import { defineConfig } from 'markopress';
import loggerPlugin from './plugins/logger';
export default defineConfig({
plugins: [
loggerPlugin(),
],
});
# Plugin Hooks
# Config Hook
Transform the configuration before the build starts.
import type { MarkoPressPlugin, ResolvedConfig } from 'markopress/plugin';
export default function configPlugin(): MarkoPressPlugin {
return {
name: 'config-plugin',
config(config: ResolvedConfig) {
// Modify config
config.site.title = 'Modified Title';
// Add custom data
config.site.customData = {
apiKey: process.env.API_KEY,
};
return config;
},
};
}
# Content Loaded Hook
Process and transform content after it’s loaded.
import type { MarkoPressPlugin, ContentContext, PageData } from 'markopress/plugin';
export default function contentPlugin(): MarkoPressPlugin {
return {
name: 'content-plugin',
contentLoaded(ctx: ContentContext) {
const pages = ctx.getPages();
for (const page of pages) {
// Transform frontmatter
page.frontmatter.lastModified = new Date().toISOString();
// Add computed fields
page.frontmatter.wordCount = page.content.split(/\s+/).length;
// Add excerpt if missing
if (!page.excerpt) {
page.excerpt = page.content.slice(0, 200) + '...';
}
// Update the page
ctx.addPage(page);
}
},
};
}
# Build Hooks
Run code before or after the build.
import type { MarkoPressPlugin, BuildContext } from 'markopress/plugin';
import { writeFile, mkdir } from 'fs/promises';
export default function buildPlugin(): MarkoPressPlugin {
return {
name: 'build-plugin',
async beforeBuild(ctx: BuildContext) {
ctx.utils.log('Preparing build...');
// Generate files before build
await mkdir('dist/generated', { recursive: true });
await writeFile(
'dist/generated/build-info.json',
JSON.stringify({
timestamp: new Date().toISOString(),
pages: ctx.content.pages.length,
})
);
},
async afterBuild(ctx: BuildContext) {
ctx.utils.log('Build complete!');
// Post-process build output
const stats = {
pages: ctx.content.pages.length,
posts: ctx.content.blog.length,
routes: Object.keys(ctx.routes).length,
};
await writeFile(
'dist/stats.json',
JSON.stringify(stats, null, 2)
);
},
};
}
# Markdown Hook
Extend markdown functionality with markdown-it plugins.
import type { MarkoPressPlugin } from 'markopress/plugin';
import markdownItEmoji from 'markdown-it-emoji';
import markdownItFootnote from 'markdown-it-footnote';
export default function markdownPlugin(): MarkoPressPlugin {
return {
name: 'markdown-plugin',
extendMarkdown(md) {
// Add emoji support
md.use(markdownItEmoji);
// Add footnotes
md.use(markdownItFootnote);
// Custom renderer
const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
// Add target="_blank" to external links
const hrefIndex = tokens[idx].attrIndex('href');
if (hrefIndex >= 0) {
const href = tokens[idx].attrs[hrefIndex][1];
if (href.startsWith('http')) {
tokens[idx].attrPush(['target', '_blank']);
tokens[idx].attrPush(['rel', 'noopener']);
}
}
return defaultRender(tokens, idx, options, env, self);
};
},
};
}
# Custom Markdown Containers
Add custom container syntax like :::tip:
import type { MarkoPressPlugin } from 'markopress/plugin';
import type MarkdownIt from 'markdown-it';
export default function containerPlugin(): MarkoPressPlugin {
return {
name: 'container-plugin',
extendMarkdown(md: typeof MarkdownIt) {
// Tip container
md.use(require('markdown-it-container'), 'tip', {
render: (tokens: any[], idx: number) => {
if (tokens[idx].nesting === 1) {
return '<div class="callout callout--tip">\n';
}
return '</div>\n';
},
});
// Warning container
md.use(require('markdown-it-container'), 'warning', {
render: (tokens: any[], idx: number) => {
if (tokens[idx].nesting === 1) {
return '<div class="callout callout--warning">\n';
}
return '</div>\n';
},
});
// Danger container
md.use(require('markdown-it-container'), 'danger', {
render: (tokens: any[], idx: number) => {
if (tokens[idx].nesting === 1) {
return '<div class="callout callout--danger">\n';
}
return '<div data-marko-tag="0"></div>`;
await writeFile('dist/sitemap-custom.xml', xml);
ctx.utils.log('Custom sitemap generated');
},
};
}
# Image Optimization Plugin
Optimize images in content:
// plugins/image-optimize.ts
import type { MarkoPressPlugin, ContentContext, PageData } from 'markopress/plugin';
import sharp from 'sharp';
export default function imageOptimizePlugin(): MarkoPressPlugin {
return {
name: 'image-optimize-plugin',
async contentLoaded(ctx: ContentContext) {
const pages = ctx.getPages();
for (const page of pages) {
// Find image references
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const images = Array.from(page.html.matchAll(imageRegex));
for (const match of images) {
const alt = match[1];
const src = match[2];
// Skip external images
if (src.startsWith('http')) continue;
// Optimize image
const inputPath = join(process.cwd(), 'public', src);
const outputPath = join(process.cwd(), 'dist', src);
await sharp(inputPath)
.resize(800, 600, { fit: 'inside' })
.jpeg({ quality: 80 })
.toFile(outputPath);
ctx.utils.log(`Optimized: ${src}`);
}
}
},
};
}
# Search Index Plugin
Generate a search index for client-side search:
// plugins/search-index.ts
import type { MarkoPressPlugin, BuildContext } from 'markopress/plugin';
import { writeFile } from 'fs/promises';
interface SearchDocument {
id: string;
title: string;
url: string;
content: string;
excerpt: string;
}
export default function searchIndexPlugin(): MarkoPressPlugin {
return {
name: 'search-index-plugin',
async afterBuild(ctx: BuildContext) {
const documents: SearchDocument[] = [];
// Index all pages
for (const page of ctx.content.pages) {
documents.push({
id: page.id,
title: page.frontmatter.title as string || 'Untitled',
url: page.routePath,
content: page.content,
excerpt: page.excerpt || '',
});
}
// Index blog posts
for (const post of ctx.content.blog) {
documents.push({
id: post.id,
title: post.frontmatter.title as string || 'Untitled',
url: post.routePath,
content: post.content,
excerpt: post.excerpt || '',
});
}
// Write search index
await writeFile(
'dist/search-index.json',
JSON.stringify(documents, null, 2)
);
ctx.utils.log(`Search index created with ${documents.length} documents`);
},
};
}
# Plugin Configuration
Plugins can accept configuration options:
// plugins/my-plugin.ts
export interface MyPluginOptions {
enabled?: boolean;
customOption?: string;
}
export default function myPlugin(
options: MyPluginOptions = {}
): MarkoPressPlugin {
const { enabled = true, customOption = 'default' } = options;
return {
name: 'my-plugin',
config(config) {
if (!enabled) return config;
// Use options
config.site.customField = customOption;
return config;
},
};
}
Use with options:
// markopress.config.ts
export default defineConfig({
plugins: [
['my-plugin', {
enabled: true,
customOption: 'custom value',
}],
],
});
# Publishing Plugins
# Package Structure
my-markopress-plugin/
├── package.json
├── README.md
├── src/
│ └── index.ts
└── tsconfig.json
# package.json
{
"name": "@username/markopress-plugin-myplugin",
"version": "1.0.0",
"description": "My awesome MarkoPress plugin",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"keywords": [
"markopress",
"plugin",
"ssg"
],
"peerDependencies": {
"markopress": "^1.0.0"
},
"devDependencies": {
"markopress": "^1.0.0",
"typescript": "^5.0.0"
}
}
# Plugin Entry Point
// src/index.ts
import type { MarkoPressPlugin } from 'markopress/plugin';
export interface MyPluginOptions {
// Plugin options
}
export default function myPlugin(
options?: MyPluginOptions
): MarkoPressPlugin {
return {
name: '@username/markopress-plugin-myplugin',
// Plugin implementation
};
}
export type { MyPluginOptions };
# Naming Convention
Plugin packages should follow this naming pattern:
@username/markopress-plugin-{plugin-name}
markopress-plugin-{plugin-name} # For official plugins
# Best Practices
# 1. Type Safety
Always use TypeScript and export types:
export interface MyPluginOptions {
enabled?: boolean;
}
export default function myPlugin(
options: MyPluginOptions = {}
): MarkoPressPlugin {
// ...
}
# 2. Error Handling
Handle errors gracefully:
contentLoaded(ctx: ContentContext) {
try {
// Plugin logic
} catch (error) {
ctx.utils.error(`Plugin error: ${error}`);
// Don't throw - let build continue
}
}
# 3. Performance
Avoid heavy operations in hooks:
async contentLoaded(ctx: ContentContext) {
// ✅ Good: Efficient iteration
for (const page of ctx.getPages()) {
// Light processing
}
// ❌ Bad: Blocking operations
await Promise.all(ctx.getPages().map(async (page) => {
await heavyOperation(page); // Don't do this
}));
}
# 4. Documentation
Document your plugin thoroughly:
/**
* My awesome plugin
*
* @example
* ```ts
* import myPlugin from '@user/markopress-plugin-my';
*
* export default defineConfig({
* plugins: [myPlugin()],
* });
* ```
*/
export default function myPlugin(
options: MyPluginOptions = {}
): MarkoPressPlugin {
// ...
}
# Testing Plugins
Create a test site to verify your plugin:
// markopress.config.ts
import { defineConfig } from 'markopress';
import myPlugin from './plugins/my-plugin';
export default defineConfig({
plugins: [
myPlugin({
// Test options
}),
],
});
Run the dev server and build:
npm run dev
npm run build
Check for:
- ✅ Plugin loads without errors
- ✅ Hooks execute in correct order
- ✅ Content is transformed correctly
- ✅ Build output is valid
# Plugin Hook Order
Hooks execute in this order:
- config - Config transformation
- extendMarkdown - Markdown extension
- contentLoaded - Content processing
- beforeBuild - Pre-build preparation
- extendRoutes - Route generation
- afterBuild - Post-build processing
# API Reference
# PluginContext
interface PluginContext {
config: ResolvedConfig;
utils: {
log: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
};
}
# ContentContext
interface ContentContext extends PluginContext {
addPage: (page: PageData) => void;
addPost: (post: PostData) => void;
getPages: () => PageData[];
getPosts: () => PostData[];
}
# BuildContext
interface BuildContext extends PluginContext {
content: {
pages: PageData[];
docs: PageData[];
blog: PostData[];
};
routes: RouteManifest;
}
# Examples
See the official plugins for reference:
# Next Steps
- 📖 Read API Reference
- 🎨 Learn about Theming
- 🚀 Deploy your Site