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:

  1. config - Config transformation
  2. extendMarkdown - Markdown extension
  3. contentLoaded - Content processing
  4. beforeBuild - Pre-build preparation
  5. extendRoutes - Route generation
  6. 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