Overview
File handler plugins teach Scully how to process different file types (like Markdown, AsciiDoc, or custom formats) and convert them to HTML. They work with thecontentFolder router plugin to process files in your content directories.
When to Use File Handler Plugins
Create a file handler plugin when you need to:- Support custom file formats (
.mdx,.rst,.adoc, etc.) - Add custom markdown processing features
- Apply transformations to content files
- Integrate with specific content management systems
- Process file metadata in unique ways
File Handler Plugin Signature
import { HandledRoute } from '@scullyio/scully';
type FilePlugin = {
(html: string, route?: HandledRoute): Promise<string>;
[AlternateExtensionsForFilePlugin]?: string[];
};
Parameters
html: The raw content of the file as a stringroute: TheHandledRouteobject containing route information and metadata
Return Value
APromise<string> containing the processed HTML.
Basic Example: HTML Handler
The simplest file handler just returns the content as-is:// scully/plugins/html-handler.plugin.ts
import { registerPlugin } from '@scullyio/scully';
const htmlHandler = async (raw: string): Promise<string> => {
return raw;
};
// Register for .html files
registerPlugin('fileHandler', 'html', htmlHandler);
Markdown Handler Example
Here’s how Scully’s built-in markdown handler works:// scully/plugins/markdown-handler.plugin.ts
import {
registerPlugin,
getConfig,
setConfig,
logWarn
} from '@scullyio/scully';
import { marked } from 'marked';
import Prism from 'prismjs';
// Import language support
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-json';
interface MarkedConfig {
enableSyntaxHighlighting: boolean;
}
// Custom renderer for code blocks
const renderer = new marked.Renderer();
renderer.code = function (code, lang, escaped) {
code = this.options.highlight(code, lang);
if (!lang) {
return `<pre><code>${code}</code></pre>`;
}
const langClass = `language-${lang}`;
return `<pre class="${langClass}"><code class="${langClass}">${code}</code></pre>`;
};
const markdownPlugin = async (raw: string): Promise<string> => {
const config = getConfig<MarkedConfig>(markdownPlugin);
if (config.enableSyntaxHighlighting) {
marked.setOptions({
renderer,
highlight: (code, lang) => {
lang = lang || 'typescript';
if (!Prism.languages[lang]) {
logWarn(`
Language '${lang}' not available in Prism.js.
Add it to your ScullyConfig.ts:
import 'prismjs/components/prism-${lang}'
`);
return code;
}
return Prism.highlight(code, Prism.languages[lang], lang);
},
pedantic: false,
gfm: true,
breaks: false,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false,
});
}
return marked(raw);
};
// Set default configuration
setConfig(markdownPlugin, {
enableSyntaxHighlighting: true,
});
// Register with alternate extensions
registerPlugin(
'fileHandler',
'md',
markdownPlugin,
['markdown'] // Alternate extensions
);
Usage in Config
// scully.config.ts
import { ScullyConfig, setPluginConfig } from '@scullyio/scully';
import './scully/plugins/markdown-handler.plugin';
// Configure markdown plugin
setPluginConfig('md', {
enableSyntaxHighlighting: true,
});
export const config: ScullyConfig = {
routes: {
'/blog/:slug': {
type: 'contentFolder',
slug: {
folder: './blog',
},
},
},
};
Advanced Example: AsciiDoc Handler
// scully/plugins/asciidoc-handler.plugin.ts
import {
registerPlugin,
HandledRoute,
logError,
logOk,
yellow
} from '@scullyio/scully';
import Processor from 'asciidoctor';
const asciidoctor = Processor();
const asciiDocPlugin = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
try {
const html = asciidoctor.convert(raw, {
safe: 'unsafe',
attributes: {
// Add custom attributes
'source-highlighter': 'highlight.js',
'icons': 'font',
'toc': 'left',
},
}) as string;
if (route) {
logOk(`Processed AsciiDoc file: ${yellow(route.route)}`);
}
return html;
} catch (error) {
logError('AsciiDoc processing error:', error);
throw new Error(
`Failed to process AsciiDoc file. ` +
`Make sure you have installed: npm install asciidoctor`
);
}
};
// Register with multiple extensions
registerPlugin(
'fileHandler',
'adoc',
asciiDocPlugin,
['asciidoc', 'asc'] // Alternate extensions
);
export const asciiDoc = 'adoc';
Alternate File Extensions
The fourth parameter ofregisterPlugin lets you specify alternate file extensions:
registerPlugin(
'fileHandler',
'md', // Primary extension
markdownPlugin,
['markdown', 'mdown', 'mkd'] // Alternates
);
post.mdpost.markdownpost.mdownpost.mkd
Working with Route Data
Access frontmatter and route information:const customHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
if (route && route.data) {
const { title, author, tags } = route.data;
console.log('Processing:', title);
console.log('Author:', author);
console.log('Tags:', tags);
}
// Process content with route context
const html = processContent(raw);
return html;
};
Custom MDX Handler Example
Here’s an advanced example for processing MDX files:// scully/plugins/mdx-handler.plugin.ts
import {
registerPlugin,
HandledRoute,
logError,
getConfig,
setConfig
} from '@scullyio/scully';
import { compile } from '@mdx-js/mdx';
import remarkGfm from 'remark-gfm';
import remarkFrontmatter from 'remark-frontmatter';
import rehypeHighlight from 'rehype-highlight';
interface MdxConfig {
remarkPlugins?: any[];
rehypePlugins?: any[];
}
const mdxHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
const config = getConfig<MdxConfig>(mdxHandler);
try {
// Compile MDX to JavaScript
const result = await compile(raw, {
remarkPlugins: [
remarkGfm,
remarkFrontmatter,
...(config.remarkPlugins || []),
],
rehypePlugins: [
rehypeHighlight,
...(config.rehypePlugins || []),
],
development: false,
});
// Convert to string
let html = String(result);
// Add wrapper for Scully
html = `
<div class="mdx-content">
${html}
</div>
`;
return html;
} catch (error) {
logError('MDX processing error:', error);
return `<div class="error">Failed to process MDX content</div>`;
}
};
// Set default configuration
setConfig(mdxHandler, {
remarkPlugins: [],
rehypePlugins: [],
});
registerPlugin('fileHandler', 'mdx', mdxHandler);
export const mdx = 'mdx';
Custom Textile Handler
Example of a custom markup language handler:// scully/plugins/textile-handler.plugin.ts
import { registerPlugin, HandledRoute } from '@scullyio/scully';
import textile from 'textile-js';
const textileHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
try {
// Convert textile to HTML
const html = textile(raw);
// Wrap in semantic HTML
return `
<article class="textile-content">
${html}
</article>
`;
} catch (error) {
console.error('Textile processing error:', error);
return raw; // Fallback to raw content
}
};
registerPlugin(
'fileHandler',
'textile',
textileHandler,
['txt'] // Also handle .txt files
);
Adding Custom Transforms
You can add custom transformations to existing formats:// scully/plugins/enhanced-markdown.plugin.ts
import { registerPlugin, HandledRoute } from '@scullyio/scully';
import { marked } from 'marked';
const enhancedMarkdownHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
// Custom preprocessing
let processed = raw;
// 1. Replace custom shortcodes
processed = processed.replace(
/\[\[youtube:([^\]]+)\]\]/g,
(_, id) => `<div class="youtube-embed">
<iframe src="https://www.youtube.com/embed/${id}"></iframe>
</div>`
);
// 2. Replace custom callouts
processed = processed.replace(
/:::tip([\s\S]*?):::/g,
(_, content) => `<div class="callout callout-tip">${content}</div>`
);
processed = processed.replace(
/:::warning([\s\S]*?):::/g,
(_, content) => `<div class="callout callout-warning">${content}</div>`
);
// 3. Process with marked
let html = marked(processed);
// 4. Post-processing
// Add target="_blank" to external links
html = html.replace(
/<a href="(https?:\/\/[^"]+)"/g,
'<a href="$1" target="_blank" rel="noopener noreferrer"'
);
return html;
};
registerPlugin(
'fileHandler',
'md',
enhancedMarkdownHandler,
['markdown'],
{ replaceExistingPlugin: true } // Replace built-in handler
);
Working with Frontmatter
File handlers receive already-parsed frontmatter in the route data:const frontmatterAwareHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
let html = processContent(raw);
// Access frontmatter from route.data
if (route?.data) {
const { layout, customClass } = route.data;
// Apply custom layout
if (layout === 'hero') {
html = `<div class="hero-layout">${html}</div>`;
}
// Add custom classes
if (customClass) {
html = `<div class="${customClass}">${html}</div>`;
}
}
return html;
};
Plugin Configuration
Make your handler configurable:import { getConfig, setConfig } from '@scullyio/scully';
interface HandlerConfig {
wrapInArticle: boolean;
addToc: boolean;
syntaxHighlighting: boolean;
}
const configurableHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
const config = getConfig<HandlerConfig>(configurableHandler);
let html = convertToHtml(raw);
if (config.addToc) {
html = addTableOfContents(html);
}
if (config.wrapInArticle) {
html = `<article>${html}</article>`;
}
return html;
};
// Set defaults
setConfig(configurableHandler, {
wrapInArticle: true,
addToc: false,
syntaxHighlighting: true,
});
registerPlugin('fileHandler', 'custom', configurableHandler);
Error Handling
Always handle errors gracefully:const robustHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
try {
return await processContent(raw);
} catch (error) {
logError(
`Failed to process file ${route?.templateFile || 'unknown'}:`,
error
);
// Return error message in HTML
return `
<div class="processing-error">
<h2>Content Processing Error</h2>
<p>Failed to process this content file.</p>
<pre>${error.message}</pre>
</div>
`;
}
};
Best Practices
1. Validate Dependencies
const safeHandler = async (raw: string): Promise<string> => {
try {
const processor = require('some-processor');
return processor(raw);
} catch (error) {
throw new Error(
'Missing dependency. Install it with: npm install some-processor'
);
}
};
2. Cache Compiled Resources
let cachedRenderer: any = null;
const efficientHandler = async (raw: string): Promise<string> => {
if (!cachedRenderer) {
cachedRenderer = createRenderer();
}
return cachedRenderer.render(raw);
};
3. Provide Meaningful Errors
const helpfulHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
if (!raw || raw.trim().length === 0) {
logWarn(
`Empty content file: ${route?.templateFile || 'unknown'}`
);
return '<p>This page has no content.</p>';
}
return processContent(raw);
};
4. Support Multiple Extensions
Make your handler flexible:registerPlugin(
'fileHandler',
'md',
markdownHandler,
['markdown', 'mdown', 'mkd', 'mdwn']
);
5. Log Processing Activity
import { logOk, yellow } from '@scullyio/scully';
const verboseHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
const filename = route?.templateFile || 'unknown';
console.log(`Processing ${yellow(filename)}`);
const html = processContent(raw);
logOk(`Successfully processed ${yellow(filename)}`);
return html;
};
Testing File Handler Plugins
import { findPlugin, HandledRoute } from '@scullyio/scully';
import './my-file-handler.plugin';
describe('myFileHandler', () => {
const mockRoute: HandledRoute = {
route: '/test',
type: 'contentFolder',
templateFile: '/content/test.custom',
data: {
title: 'Test File',
},
};
it('should convert custom format to HTML', async () => {
const plugin = findPlugin('custom', 'fileHandler');
const input = '# Hello World\n\nThis is a test.';
const result = await plugin(input, mockRoute);
expect(result).toContain('<h1>');
expect(result).toContain('Hello World');
});
it('should handle empty content', async () => {
const plugin = findPlugin('custom', 'fileHandler');
const result = await plugin('', mockRoute);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
it('should respect configuration', async () => {
setConfig(myHandler, { wrapInArticle: true });
const plugin = findPlugin('custom', 'fileHandler');
const result = await plugin('content', mockRoute);
expect(result).toContain('<article>');
});
});
Complete Example: ReStructuredText Handler
// scully/plugins/rst-handler.plugin.ts
import {
registerPlugin,
HandledRoute,
logError,
logOk,
yellow
} from '@scullyio/scully';
import { execSync } from 'child_process';
import { writeFileSync, readFileSync, unlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const rstHandler = async (
raw: string,
route?: HandledRoute
): Promise<string> => {
const tempFile = join(tmpdir(), `scully-rst-${Date.now()}.rst`);
const outputFile = tempFile.replace('.rst', '.html');
try {
// Write content to temp file
writeFileSync(tempFile, raw, 'utf-8');
// Convert using rst2html (must be installed)
execSync(
`rst2html.py "${tempFile}" "${outputFile}"`,
{ encoding: 'utf-8' }
);
// Read result
const html = readFileSync(outputFile, 'utf-8');
// Clean up
unlinkSync(tempFile);
unlinkSync(outputFile);
if (route) {
logOk(`Processed RST file: ${yellow(route.route)}`);
}
return html;
} catch (error) {
logError('RST processing error:', error);
throw new Error(
'Failed to process RST file. ' +
'Make sure docutils is installed: pip install docutils'
);
}
};
registerPlugin(
'fileHandler',
'rst',
rstHandler,
['rest', 'restx']
);
export const rst = 'rst';
Next Steps
- Learn about Testing Plugins
- Explore Router Plugins
- Review Render Plugins

