Skip to main content
Render plugins (also called post-processing plugins) run after Scully renders a route and allow you to manipulate the HTML output. They come in two flavors: HTML string-based and DOM-based.

Purpose

Render plugins are responsible for:
  • Transforming HTML content after rendering
  • Injecting scripts, styles, or meta tags
  • Modifying DOM structure or attributes
  • Adding analytics, SEO enhancements, or accessibility features
  • Cleaning up or optimizing the rendered output

Plugin Types

HTML String Plugins (postProcessByHtml)

Process HTML as a string - useful for simple text replacements and injections.
type postProcessByHtmlPlugin = (
  html?: string,
  route?: HandledRoute
) => Promise<string>;

DOM Plugins (postProcessByDom)

Process HTML using JSDOM - useful for complex DOM manipulations.
import { JSDOM } from 'jsdom';

type postProcessByDomPlugin = (
  dom?: JSDOM,
  route?: HandledRoute
) => Promise<JSDOM>;

Lifecycle

  1. Scully renders the route using Puppeteer (or configured renderer)
  2. The initial HTML is captured
  3. DOM plugins run first in registration order
    • JSDOM is created from the HTML
    • Each DOM plugin receives and modifies the JSDOM object
    • JSDOM is converted back to HTML string
  4. HTML plugins run next in registration order
    • Each HTML plugin receives and modifies the HTML string
  5. The final HTML is written to the output file

When to Use

Use HTML String Plugins When:

  • Making simple text replacements
  • Injecting snippets at specific locations
  • Performing regex-based transformations
  • You need maximum performance

Use DOM Plugins When:

  • Manipulating DOM structure (adding/removing elements)
  • Working with attributes or classes
  • Traversing or querying the DOM
  • You need reliable HTML parsing

Implementation Examples

Basic HTML String Plugin

import { registerPlugin } from '@scullyio/scully';
import { HandledRoute } from '@scullyio/scully';

export const myHtmlPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  // Simple string replacement
  return html.replace(
    '</head>',
    `  <meta name="custom" content="${route.route}" />\n</head>`
  );
};

registerPlugin('postProcessByHtml', 'myHtmlPlugin', myHtmlPlugin);

Analytics Injection Plugin

export const analyticsPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  const analyticsScript = `
    <script>
      // Google Analytics
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());
      gtag('config', 'GA_MEASUREMENT_ID', {
        page_path: '${route.route}'
      });
    </script>
    <script async src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"></script>
  `;
  
  return html.replace('</head>', `${analyticsScript}</head>`);
};

registerPlugin('postProcessByHtml', 'analytics', analyticsPlugin);

DOM Manipulation Plugin

import { JSDOM } from 'jsdom';
import { registerPlugin } from '@scullyio/scully';
import { HandledRoute } from '@scullyio/scully';

export const linkPlugin = async (
  dom: JSDOM,
  route: HandledRoute
): Promise<JSDOM> => {
  const { window } = dom;
  const { document } = window;
  
  // Find all external links
  const links = Array.from(document.querySelectorAll('a'));
  
  links.forEach(link => {
    const href = link.getAttribute('href');
    
    // Add target="_blank" to external links
    if (href && href.startsWith('http')) {
      link.setAttribute('target', '_blank');
      link.setAttribute('rel', 'noopener noreferrer');
    }
  });
  
  return dom;
};

registerPlugin('postProcessByDom', 'externalLinks', linkPlugin);

Content Injection Plugin

This is how Scully’s built-in contentFolder plugin injects content:
import { JSDOM } from 'jsdom';
import { registerPlugin } from '@scullyio/scully';

export async function contentRenderPlugin(
  dom: JSDOM,
  route: HandledRoute
): Promise<JSDOM> {
  const file = route.templateFile;
  
  try {
    const extension = file.split('.').pop();
    
    // Read the content file
    const { fileContent } = await readFileAndCheckPrePublishSlug(file);
    
    // Convert and inject the content
    return convertAndInjectContent(dom, fileContent, extension, route);
  } catch (e) {
    console.error(`Error rendering content for ${route.route}`);
    return dom;
  }
}

registerPlugin('postProcessByDom', 'contentFolder', contentRenderPlugin);

SEO Enhancement Plugin

export const seoPlugin = async (
  dom: JSDOM,
  route: HandledRoute
): Promise<JSDOM> => {
  const { window } = dom;
  const { document } = window;
  const head = document.querySelector('head');
  
  if (!head) return dom;
  
  // Add Open Graph tags
  const createMeta = (property: string, content: string) => {
    const meta = document.createElement('meta');
    meta.setAttribute('property', property);
    meta.setAttribute('content', content);
    return meta;
  };
  
  if (route.data?.title) {
    head.appendChild(createMeta('og:title', route.data.title));
  }
  
  if (route.data?.description) {
    head.appendChild(createMeta('og:description', route.data.description));
  }
  
  head.appendChild(createMeta('og:url', `https://example.com${route.route}`));
  head.appendChild(createMeta('og:type', 'article'));
  
  // Add Twitter Card tags
  const createTwitterMeta = (name: string, content: string) => {
    const meta = document.createElement('meta');
    meta.setAttribute('name', name);
    meta.setAttribute('content', content);
    return meta;
  };
  
  head.appendChild(createTwitterMeta('twitter:card', 'summary_large_image'));
  
  if (route.data?.title) {
    head.appendChild(createTwitterMeta('twitter:title', route.data.title));
  }
  
  return dom;
};

registerPlugin('postProcessByDom', 'seo', seoPlugin);

Configuration

Global Post Renderers

Apply plugins to all routes:
import { ScullyConfig } from '@scullyio/scully';
import './my-plugins';

export const config: ScullyConfig = {
  defaultPostRenderers: ['seo', 'analytics', 'externalLinks'],
  // ... other config
};

Route-Specific Post Renderers

Apply plugins to specific routes:
export const config: ScullyConfig = {
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      postRenderers: ['seo', 'analytics', 'contentFolder'],
      slug: {
        folder: './blog',
      },
    },
  },
};

Plugin Order

Plugins run in this order:
  1. Route type plugin (if it’s a render plugin)
  2. Route-specific postRenderers
  3. Global defaultPostRenderers
// If route has:
// - type: 'contentFolder'
// - postRenderers: ['plugin1', 'plugin2']
// And config has:
// - defaultPostRenderers: ['plugin3', 'plugin4']

// Execution order:
// 1. contentFolder (DOM plugin)
// 2. plugin1
// 3. plugin2
// 4. plugin3
// 5. plugin4

Working with JSDOM

Accessing the Document

const { window } = dom;
const { document } = window;

// Use standard DOM APIs
const element = document.querySelector('.my-class');
const elements = Array.from(document.querySelectorAll('a'));

Creating Elements

const div = document.createElement('div');
div.className = 'my-class';
div.textContent = 'Hello World';
document.body.appendChild(div);

Modifying Attributes

element.setAttribute('data-custom', 'value');
element.classList.add('active');
element.style.color = 'red';

Serializing Back to HTML

Scully handles this automatically - just return the modified JSDOM object:
export const myPlugin = async (dom: JSDOM): Promise<JSDOM> => {
  // Make modifications
  // ...
  
  // Scully will call dom.serialize() internally
  return dom;
};

Best Practices

  1. Choose the right plugin type: Use HTML plugins for simple tasks, DOM plugins for complex ones
  2. Handle missing elements gracefully: Always check if elements exist before manipulating
  3. Avoid blocking operations: Use async/await for I/O operations
  4. Log errors: Use Scully’s logging utilities for consistent output
  5. Test thoroughly: Plugins run on every route, so bugs multiply
  6. Keep it fast: Render plugins can significantly impact build time
  7. Be idempotent: Plugins may run multiple times during development
  8. Use route data: Access route.data for metadata from frontmatter or router plugins

Error Handling

export const safePlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  try {
    // Your plugin logic
    return html.replace('foo', 'bar');
  } catch (error) {
    console.error(`Error in plugin for route ${route.route}:`, error);
    // Return original HTML on error
    return html;
  }
};

Built-in Render Plugins

Scully includes several built-in render plugins:
  • contentFolder: Injects content from markdown/content files
  • contentTextRender: Alternative content rendering
  • seoHrefCompletion: Completes relative URLs for SEO

Build docs developers (and LLMs) love