Skip to main content
File handler plugins are responsible for transforming content files (like Markdown, AsciiDoc, or custom formats) into HTML that can be injected into your Angular application.

Purpose

File handler plugins are responsible for:
  • Converting content files to HTML (Markdown → HTML, AsciiDoc → HTML, etc.)
  • Processing file content with custom logic
  • Supporting multiple file extensions
  • Enabling custom content formats

Function Signature

type FilePlugin = {
  (html: string, route?: HandledRoute): Promise<string>;
  [AlternateExtensionsForFilePlugin]?: string[];
};

interface HandledRoute {
  /** the _complete_ route */
  route: string;
  /** the path to the file for a content file */
  templateFile?: string;
  /** additional data that will end up in scully.routes.json */
  data?: RouteData;
  // ... other properties
}

Lifecycle

  1. Router plugin discovers a content file (e.g., blog/my-post.md)
  2. Router plugin creates a HandledRoute with templateFile pointing to the file
  3. Render plugin reads the file content
  4. File handler plugin is called based on file extension
  5. Plugin transforms the raw content to HTML
  6. HTML is injected into the Angular application
  7. Post-render plugins process the final HTML

When to Use

Create a file handler plugin when you need to:
  • Support a new content file format (.rst, .org, .tex, etc.)
  • Add custom processing to existing formats (Markdown with special syntax)
  • Transform content with custom logic (encrypt, compress, etc.)
  • Integrate with external content processing tools

Implementation Examples

Basic File Handler Plugin

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

export const myFileHandler = async (
  raw: string,
  route?: HandledRoute
): Promise<string> => {
  // Transform the raw content to HTML
  const html = transformToHtml(raw);
  
  return html;
};

// Register for .custom extension
registerPlugin('fileHandler', 'custom', myFileHandler);

Markdown File Handler

Here’s Scully’s built-in Markdown handler:
import { registerPlugin } from '@scullyio/scully';
import { getConfig, setConfig } from '@scullyio/scully';
import { marked } from 'marked';
import Prism from 'prismjs';

// Import syntax highlighting languages
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-typescript';

export 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]) {
          console.warn(`Language '${lang}' not available in Prism.js`);
          return code;
        }
        
        return Prism.highlight(code, Prism.languages[lang]);
      },
      gfm: true,
      breaks: false,
      smartLists: true,
    });
  }
  
  return marked(raw);
};

// Set default config
setConfig(markdownPlugin, {
  enableSyntaxHighlighting: false,
});

// Register for .md and .markdown extensions
registerPlugin('fileHandler', 'md', markdownPlugin, ['markdown']);

HTML Pass-Through Handler

The simplest file handler just returns the content as-is:
import { registerPlugin } from '@scullyio/scully';

registerPlugin(
  'fileHandler',
  'html',
  async (raw: string) => raw,
  ['html']
);

AsciiDoc Handler

import { registerPlugin } from '@scullyio/scully';
import Asciidoctor from 'asciidoctor';

const asciidoctor = Asciidoctor();

export const asciidocPlugin = async (raw: string): Promise<string> => {
  // Convert AsciiDoc to HTML
  const html = asciidoctor.convert(raw, {
    safe: 'safe',
    attributes: {
      showtitle: true,
      icons: 'font',
    },
  });
  
  return html as string;
};

// Register for .adoc, .asciidoc, and .asc extensions
registerPlugin('fileHandler', 'adoc', asciidocPlugin, ['asciidoc', 'asc']);

Custom Format with Metadata

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

interface CustomFile {
  metadata: Record<string, any>;
  content: string;
}

export const customFormatHandler = async (
  raw: string,
  route?: HandledRoute
): Promise<string> => {
  // Parse custom format
  const parsed: CustomFile = parseCustomFormat(raw);
  
  // Add metadata to route data
  if (route && route.data) {
    Object.assign(route.data, parsed.metadata);
  }
  
  // Transform content to HTML
  const html = transformContent(parsed.content);
  
  return html;
};

registerPlugin('fileHandler', 'custom', customFormatHandler);

Plugin with Configuration

import { registerPlugin, getConfig, setConfig } from '@scullyio/scully';
import { marked } from 'marked';

interface MarkdownOptions {
  gfm: boolean;
  breaks: boolean;
  pedantic: boolean;
}

export const configurableMarkdown = async (raw: string): Promise<string> => {
  // Get plugin configuration
  const config = getConfig<MarkdownOptions>(configurableMarkdown);
  
  // Apply configuration
  marked.setOptions({
    gfm: config.gfm,
    breaks: config.breaks,
    pedantic: config.pedantic,
  });
  
  return marked(raw);
};

// Set defaults
setConfig(configurableMarkdown, {
  gfm: true,
  breaks: false,
  pedantic: false,
});

registerPlugin('fileHandler', 'md', configurableMarkdown, ['markdown']);

Registering File Handler Plugins

Basic Registration

// Register for a single extension
registerPlugin('fileHandler', 'md', markdownPlugin);

Multiple Extensions

// Register for multiple extensions
registerPlugin(
  'fileHandler',
  'md',
  markdownPlugin,
  ['markdown', 'mdown', 'mkd']
);
The fourth parameter is an array of alternate extensions that should use the same handler.

Configuration

Setting Plugin Configuration

import { setConfig } from '@scullyio/scully';

setConfig(myFileHandler, {
  option1: 'value1',
  option2: true,
});

Getting Plugin Configuration

import { getConfig } from '@scullyio/scully';

const config = getConfig<MyConfigType>(myFileHandler);

User Configuration

Users can override plugin configuration in scully.config.ts:
import { ScullyConfig, setPluginConfig } from '@scullyio/scully';

setPluginConfig('md', {
  enableSyntaxHighlighting: true,
});

export const config: ScullyConfig = {
  // ... other config
};

How Files are Processed

1. File Discovery

Router plugin (like contentFolder) discovers files:
const handledRoute: HandledRoute = {
  route: '/blog/my-post',
  type: 'contentFolder',
  templateFile: '/path/to/blog/my-post.md',
  data: { title: 'My Post', author: 'John' },
};

2. File Reading

Render plugin reads the file content:
const fileContent = readFileSync(route.templateFile, 'utf-8');

3. File Handler Selection

Scully selects the handler based on file extension:
const ext = 'md'; // from 'my-post.md'
const handler = plugins.fileHandler[ext];

4. Content Transformation

File handler transforms the content:
const html = await handler(fileContent, route);

5. HTML Injection

The HTML is injected into the Angular application:
// Find the scully-content element and inject HTML
const scullyContent = document.querySelector('scully-content');
scullyContent.innerHTML = html;

Extension Lookup

Scully looks up file handlers in this order:
  1. Exact extension match: .mdplugins.fileHandler['md']
  2. Alternate extensions: Check plugin[AlternateExtensionsForFilePlugin] array
  3. If no handler found, file is skipped with a warning
function hasContentPlugin(extension: string): boolean {
  extension = extension.toLowerCase().trim().replace(/^\./, '');
  
  return Object.entries(plugins.fileHandler).some(
    ([name, plugin]) =>
      extension === name.toLowerCase() ||
      (Array.isArray(plugin[AlternateExtensionsForFilePlugin]) &&
        plugin[AlternateExtensionsForFilePlugin].includes(extension))
  );
}

Best Practices

  1. Return valid HTML: File handlers must return valid HTML strings
  2. Handle errors gracefully: Return empty string or default content on error
  3. Preserve frontmatter: Don’t include YAML/TOML frontmatter in output
  4. Support configuration: Use setConfig/getConfig for user customization
  5. Register alternate extensions: Support common variations (.md, .markdown)
  6. Log warnings: Inform users of issues without breaking the build
  7. Keep it synchronous when possible: Avoid async operations unless necessary
  8. Document configuration: Provide clear TypeScript interfaces for config
  9. Test with edge cases: Empty files, malformed content, missing metadata
  10. Performance matters: File handlers run on every content file

Frontmatter Handling

Frontmatter is typically handled before the file handler is called:
// This is done by Scully's content render utilities
const { fileContent, meta } = await readFileAndCheckPrePublishSlug(file);

// fileContent has frontmatter stripped
// meta contains parsed frontmatter
route.data = { ...route.data, ...meta };

// Now call file handler with cleaned content
const html = await fileHandler(fileContent, route);
If you need to handle frontmatter yourself:
import matter from 'gray-matter';

export const myHandler = async (
  raw: string,
  route?: HandledRoute
): Promise<string> => {
  // Parse frontmatter
  const { data, content } = matter(raw);
  
  // Add to route data
  if (route && route.data) {
    Object.assign(route.data, data);
  }
  
  // Transform content
  return transformToHtml(content);
};

Built-in File Handler Plugins

Scully includes these file handler plugins:
  • md: Markdown with optional syntax highlighting (also: markdown)
  • html: Pass-through for HTML files
  • adoc: AsciiDoc support (also: asciidoc, asc)

Build docs developers (and LLMs) love