Skip to main content

Overview

Render plugins (also called postProcessByHtml plugins) allow you to transform the HTML content after Angular has rendered a page. They’re perfect for adding meta tags, injecting scripts, modifying content, or optimizing the final HTML output.

Plugin Types

There are two types of render plugins:

postProcessByHtml

Processes HTML as a string - best for simple string manipulations:
type postProcessByHtmlPlugin = (
  html?: string,
  route?: HandledRoute
) => Promise<string>;

postProcessByDom

Processes HTML using JSDOM - best for complex DOM manipulations:
import { JSDOM } from 'jsdom';

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

Basic HTML Plugin Example

Let’s create a simple plugin that adds a custom footer:
// scully/plugins/custom-footer.plugin.ts
import { 
  registerPlugin, 
  HandledRoute 
} from '@scullyio/scully';

const customFooterPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  const footer = `
    <footer class="scully-footer">
      <p>Generated by Scully at ${new Date().toISOString()}</p>
      <p>Route: ${route.route}</p>
    </footer>
  `;
  
  // Insert before closing body tag
  return html.replace('</body>', `${footer}</body>`);
};

registerPlugin(
  'postProcessByHtml',
  'customFooter',
  customFooterPlugin
);

export const customFooter = 'customFooter';

Usage in Config

// scully.config.ts
import { ScullyConfig } from '@scullyio/scully';
import './scully/plugins/custom-footer.plugin';

export const config: ScullyConfig = {
  projectRoot: './src',
  projectName: 'my-app',
  defaultPostRenderers: ['customFooter'],
  routes: {},
};
Or for specific routes:
export const config: ScullyConfig = {
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      slug: {
        folder: './blog',
      },
      postRenderers: ['customFooter'],
    },
  },
};

DOM Plugin Example

For complex HTML manipulations, use postProcessByDom:
// scully/plugins/add-table-of-contents.plugin.ts
import { registerPlugin, HandledRoute } from '@scullyio/scully';
import { JSDOM } from 'jsdom';

const addTableOfContentsPlugin = async (
  dom: JSDOM,
  route: HandledRoute
): Promise<JSDOM> => {
  const { window } = dom;
  const { document } = window;
  
  // Find all headings
  const headings = document.querySelectorAll('h2, h3, h4');
  
  if (headings.length === 0) {
    return dom; // No headings, return unchanged
  }
  
  // Create table of contents
  const toc = document.createElement('nav');
  toc.className = 'table-of-contents';
  toc.innerHTML = '<h2>Table of Contents</h2>';
  
  const list = document.createElement('ul');
  
  headings.forEach((heading, index) => {
    // Add ID to heading if it doesn't have one
    if (!heading.id) {
      heading.id = `heading-${index}`;
    }
    
    // Create TOC entry
    const li = document.createElement('li');
    li.className = `toc-${heading.tagName.toLowerCase()}`;
    
    const link = document.createElement('a');
    link.href = `#${heading.id}`;
    link.textContent = heading.textContent;
    
    li.appendChild(link);
    list.appendChild(li);
  });
  
  toc.appendChild(list);
  
  // Insert TOC before first heading
  const firstHeading = document.querySelector('h1, h2');
  if (firstHeading && firstHeading.parentNode) {
    firstHeading.parentNode.insertBefore(toc, firstHeading.nextSibling);
  }
  
  return dom;
};

registerPlugin(
  'postProcessByDom',
  'addTableOfContents',
  addTableOfContentsPlugin
);

Advanced Example: SEO Plugin

Here’s a comprehensive SEO plugin that adds meta tags:
// scully/plugins/seo.plugin.ts
import { 
  registerPlugin, 
  HandledRoute,
  getMyConfig,
  setMyConfig,
  logWarn,
  yellow 
} from '@scullyio/scully';

interface SeoConfig {
  siteName: string;
  siteUrl: string;
  twitterHandle?: string;
  defaultImage?: string;
}

const seoPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  const config = getMyConfig(seoPlugin) as SeoConfig;
  const { data = {} } = route;
  
  // Extract or use default values
  const title = data.title || 'Untitled Page';
  const description = data.description || '';
  const image = data.image || config.defaultImage || '';
  const url = `${config.siteUrl}${route.route}`;
  const author = data.author || '';
  const publishedDate = data.publishedDate || '';
  
  // Build meta tags
  const metaTags = `
    <!-- Primary Meta Tags -->
    <title>${title}</title>
    <meta name="title" content="${title}">
    <meta name="description" content="${description}">
    ${author ? `<meta name="author" content="${author}">` : ''}
    
    <!-- Open Graph / Facebook -->
    <meta property="og:type" content="website">
    <meta property="og:url" content="${url}">
    <meta property="og:title" content="${title}">
    <meta property="og:description" content="${description}">
    ${image ? `<meta property="og:image" content="${image}">` : ''}
    <meta property="og:site_name" content="${config.siteName}">
    ${publishedDate ? `<meta property="article:published_time" content="${publishedDate}">` : ''}
    
    <!-- Twitter -->
    <meta property="twitter:card" content="summary_large_image">
    <meta property="twitter:url" content="${url}">
    <meta property="twitter:title" content="${title}">
    <meta property="twitter:description" content="${description}">
    ${image ? `<meta property="twitter:image" content="${image}">` : ''}
    ${config.twitterHandle ? `<meta name="twitter:site" content="${config.twitterHandle}">` : ''}
    ${config.twitterHandle ? `<meta name="twitter:creator" content="${config.twitterHandle}">` : ''}
    
    <!-- Canonical URL -->
    <link rel="canonical" href="${url}">
  `;
  
  // Remove existing tags to avoid duplicates
  let cleanHtml = html;
  
  // Remove existing title
  cleanHtml = cleanHtml.replace(/<title>.*?<\/title>/gi, '');
  
  // Remove existing meta tags
  cleanHtml = cleanHtml.replace(
    /<meta\s+(?:name|property)=["'](title|description|author|og:|twitter:).*?>/gi,
    ''
  );
  
  // Insert new meta tags
  return cleanHtml.replace('</head>', `${metaTags}</head>`);
};

// Set default configuration
setMyConfig(seoPlugin, {
  siteName: 'My Site',
  siteUrl: 'https://example.com',
  twitterHandle: '@myhandle',
  defaultImage: 'https://example.com/default-og-image.jpg',
});

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

export const seo = 'seo';

Usage with Custom Config

// scully.config.ts
import { setPluginConfig } from '@scullyio/scully';
import { seo } from './scully/plugins/seo.plugin';

setPluginConfig(seo, {
  siteName: 'My Awesome Blog',
  siteUrl: 'https://myblog.com',
  twitterHandle: '@myblog',
  defaultImage: 'https://myblog.com/images/og-default.jpg',
});

export const config: ScullyConfig = {
  defaultPostRenderers: [seo],
  // ... rest of config
};

Plugin Configuration

Setting Plugin Config

import { setMyConfig, getMyConfig } from '@scullyio/scully';

const myPlugin = async (html: string) => {
  // Get configuration
  const config = getMyConfig(myPlugin);
  
  // Use config values
  const prefix = config.prefix || 'default-';
  
  return html;
};

// Set default configuration
setMyConfig(myPlugin, {
  prefix: 'my-prefix-',
  enabled: true,
});

Route-Specific Config

You can also set configuration per route:
import { setPluginConfig } from '@scullyio/scully';

// In scully.config.ts
export const config: ScullyConfig = {
  routes: {
    '/blog/:slug': {
      type: 'contentFolder',
      postRenderers: ['myPlugin'],
    },
  },
};

// Set config for specific route
setPluginConfig('myPlugin', 'postProcessByHtml', {
  prefix: 'blog-',
});

Working with Route Data

Access route metadata and frontmatter:
const useRouteDataPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  const { route: routePath, data, templateFile } = route;
  
  console.log('Route path:', routePath);
  console.log('Route data:', data);
  console.log('Template file:', templateFile);
  
  // Access frontmatter data
  if (data) {
    const { title, author, tags } = data;
    // Use this data to modify HTML
  }
  
  return html;
};

Common Use Cases

1. Add Analytics

const analyticsPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  const script = `
    <script>
      // Google Analytics
      window.ga=window.ga||function(){
        (ga.q=ga.q||[]).push(arguments)
      };ga.l=+new Date;
      ga('create', 'UA-XXXXX-Y', 'auto');
      ga('send', 'pageview');
    </script>
    <script async src='https://www.google-analytics.com/analytics.js'></script>
  `;
  
  return html.replace('</head>', `${script}</head>`);
};

2. Rewrite Base Href

const baseHrefPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  const href = '/my-app/';
  
  if (!html.toLowerCase().includes('<base')) {
    // Add base tag if missing
    return html.replace(
      '</head>',
      `<base href="${href}"></head>`
    );
  }
  
  // Update existing base tag
  return html.replace(
    /(<base.*href=['"]).*(["])/gi,
    `$1${href}$2`
  );
};

3. Add Schema.org Markup

const schemaPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  const { data } = route;
  
  if (!data || !data.title) {
    return html; // Skip if no data
  }
  
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: data.title,
    author: {
      '@type': 'Person',
      name: data.author,
    },
    datePublished: data.publishedDate,
    description: data.description,
  };
  
  const script = `
    <script type="application/ld+json">
      ${JSON.stringify(schema, null, 2)}
    </script>
  `;
  
  return html.replace('</head>', `${script}</head>`);
};

4. Minify HTML

import { minify } from 'html-minifier';

const minifyPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  try {
    return minify(html, {
      collapseWhitespace: true,
      removeComments: true,
      removeRedundantAttributes: true,
      useShortDoctype: true,
      minifyCSS: true,
      minifyJS: true,
    });
  } catch (error) {
    logWarn('Failed to minify HTML, returning original');
    return html;
  }
};

5. Add Reading Time

import { JSDOM } from 'jsdom';

const readingTimePlugin = async (
  dom: JSDOM,
  route: HandledRoute
): Promise<JSDOM> => {
  const { document } = dom.window;
  
  // Get main content
  const content = document.querySelector('article, main, .content');
  if (!content) return dom;
  
  // Calculate reading time
  const text = content.textContent || '';
  const wordsPerMinute = 200;
  const wordCount = text.trim().split(/\s+/).length;
  const readingTime = Math.ceil(wordCount / wordsPerMinute);
  
  // Add reading time indicator
  const indicator = document.createElement('p');
  indicator.className = 'reading-time';
  indicator.textContent = `${readingTime} min read`;
  
  // Insert at the beginning
  content.insertBefore(indicator, content.firstChild);
  
  return dom;
};

Composing Multiple Plugins

You can create a plugin that combines other plugins:
import { findPlugin } from '@scullyio/scully';

const composedSeoPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  // Find other plugins
  const metaTagsPlugin = findPlugin('metaTags');
  const schemaPlugin = findPlugin('schema');
  const analyticsPlugin = findPlugin('analytics');
  
  // Apply plugins in sequence
  let processedHtml = html;
  
  if (metaTagsPlugin) {
    processedHtml = await metaTagsPlugin(processedHtml, route);
  }
  
  if (schemaPlugin) {
    processedHtml = await schemaPlugin(processedHtml, route);
  }
  
  if (analyticsPlugin) {
    processedHtml = await analyticsPlugin(processedHtml, route);
  }
  
  return processedHtml;
};

Best Practices

1. Validate Input

const safePlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  if (!html || typeof html !== 'string') {
    logWarn('Invalid HTML input');
    return html || '';
  }
  
  // Process HTML
  return processedHtml;
};

2. Handle Errors Gracefully

const robustPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  try {
    return await processHtml(html);
  } catch (error) {
    logError('Plugin error:', error);
    return html; // Return original on error
  }
};

3. Use Conditional Processing

const conditionalPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  // Only process blog posts
  if (!route.route.startsWith('/blog/')) {
    return html;
  }
  
  // Process blog-specific content
  return processedHtml;
};

4. Log Your Actions

import { logOk, yellow } from '@scullyio/scully';

const verbosePlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  logOk(`Processing ${yellow(route.route)} with myPlugin`);
  
  const result = processHtml(html);
  
  logOk(`Completed processing ${yellow(route.route)}`);
  
  return result;
};

5. Performance Considerations

Avoid expensive operations:
// Cache compiled templates or parsers
const templateCache = new Map();

const efficientPlugin = async (
  html: string,
  route: HandledRoute
): Promise<string> => {
  // Use cached resources
  if (!templateCache.has('footer')) {
    templateCache.set('footer', compileTemplate());
  }
  
  const template = templateCache.get('footer');
  return applyTemplate(html, template);
};

Testing Render Plugins

import { findPlugin, HandledRoute } from '@scullyio/scully';
import './my-render-plugin';

describe('myRenderPlugin', () => {
  const mockRoute: HandledRoute = {
    route: '/test',
    type: 'test',
    data: {
      title: 'Test Page',
      description: 'Test description',
    },
  };
  
  it('should add custom meta tags', async () => {
    const plugin = findPlugin('myRenderPlugin');
    const html = '<html><head></head><body></body></html>';
    
    const result = await plugin(html, mockRoute);
    
    expect(result).toContain('<meta name="title"');
    expect(result).toContain('Test Page');
  });
  
  it('should handle missing data gracefully', async () => {
    const plugin = findPlugin('myRenderPlugin');
    const html = '<html><head></head><body></body></html>';
    const routeWithoutData: HandledRoute = {
      route: '/test',
      type: 'test',
    };
    
    const result = await plugin(html, routeWithoutData);
    
    expect(result).toBeTruthy();
    expect(typeof result).toBe('string');
  });
});

Next Steps

Build docs developers (and LLMs) love