Skip to main content

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 the contentFolder 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 string
  • route: The HandledRoute object containing route information and metadata

Return Value

A Promise<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 of registerPlugin lets you specify alternate file extensions:
registerPlugin(
  'fileHandler',
  'md',           // Primary extension
  markdownPlugin,
  ['markdown', 'mdown', 'mkd']  // Alternates
);
This allows Scully to process files with any of these extensions using the same handler:
  • post.md
  • post.markdown
  • post.mdown
  • post.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

Build docs developers (and LLMs) love