Overview
Render plugins (also calledpostProcessByHtml 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: {},
};
export const config: ScullyConfig = {
routes: {
'/blog/:slug': {
type: 'contentFolder',
slug: {
folder: './blog',
},
postRenderers: ['customFooter'],
},
},
};
DOM Plugin Example
For complex HTML manipulations, usepostProcessByDom:
// 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
- Learn about File Handler Plugins
- Explore Testing Plugins
- Review Router Plugins

