DOCS/Plugin Authoring

Plugin Authoring

A plugin bundles an extractor, file-contents parsers, validators, schemas, and CLI commands into a single package. This guide walks through building a complete plugin using the INI format as an example.

Plugin Interface

interface Plugin<TMetadata>
FieldPurpose
nameUnique identifier for the plugin
extensionsFile extensions this plugin handles
extractorDiscovers and extracts examples from files
fileContentsParsersTransform file content in the parse pipeline
schemasJSON Schema definitions for plugin options and metadata
validatorsValidation functions for options and metadata
commandsCLI commands contributed by the plugin

Example: INI Plugin

Here's a complete plugin that supports INI-based metadata:

Metadata Type

/**
 * A minimal plugin that extracts examples from INI-based metadata files.
 *
 * This demonstrates the full Plugin interface:
 * - name: identifies the plugin
 * - extensions: file types this plugin handles
 * - extractor: discovers examples from candidates
 * - fileContentsParsers: transforms file content in the parse pipeline
 */
import type {
  Example,
  Extractor,
  ExtractorResult,
  FileContentsParser,
  FileParseContext,
  Plugin,
} from 'functional-examples';
import { ExampleFile } from 'functional-examples';
import { readFile } from 'node:fs/promises';
import { readdirSync, type Dirent } from 'node:fs';
import path from 'node:path';

/** Metadata shape for INI-based examples. */
export interface IniMetadata {
  id: string;
  title: string;
  description?: string;
  author?: string;
  [key: string]: unknown;
}

/** Parse simple INI key=value pairs. */
function parseIni(content: string): Record<string, string> {
  const result: Record<string, string> = {};
  for (const line of content.split('\n')) {
    const trimmed = line.trim();
    // Skip comments and empty lines
    if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue;
    // Skip section headers
    if (trimmed.startsWith('[')) continue;

    const eqIndex = trimmed.indexOf('=');
    if (eqIndex > 0) {
      const key = trimmed.slice(0, eqIndex).trim();
      const value = trimmed.slice(eqIndex + 1).trim();
      result[key] = value;
    }
  }
  return result;
}

/** Create an extractor that discovers meta.ini files. */
function createIniExtractor(): Extractor<IniMetadata> {
  return {
    name: 'ini-extractor',

    async extract(candidates: Dirent[]): Promise<ExtractorResult<IniMetadata>> {
      const examples: Example<IniMetadata>[] = [];
      const claimedFiles = new Set<string>();
      const errors: { path: string; message: string }[] = [];

      // Find meta.ini files from candidates
      const iniFiles: string[] = [];

      for (const candidate of candidates) {
        const fullPath = path.join(candidate.parentPath, candidate.name);

        if (candidate.isFile() && candidate.name === 'meta.ini') {
          iniFiles.push(fullPath);
        } else if (candidate.isDirectory()) {
          const metaPath = path.join(fullPath, 'meta.ini');
          try {
            await readFile(metaPath, 'utf-8');
            iniFiles.push(metaPath);
          } catch {
            // No meta.ini in this directory
          }
        }
      }

      for (const iniFile of iniFiles) {
        try {
          const content = await readFile(iniFile, 'utf-8');
          const parsed = parseIni(content);

          if (!parsed['id'] || !parsed['title']) {
            errors.push({ path: iniFile, message: 'meta.ini must have id and title' });
            continue;
          }

          const metadata: IniMetadata = {
            id: parsed['id'],
            title: parsed['title'],
            description: parsed['description'],
            author: parsed['author'],
          };

          const exampleDir = path.dirname(iniFile);
          const files = collectFiles(exampleDir);

          for (const f of files) claimedFiles.add(f);

          examples.push({
            id: metadata.id,
            title: metadata.title,
            description: metadata.description,
            rootPath: exampleDir,
            files: files.map(
              (f) => new ExampleFile({ absolutePath: f, relativePath: path.relative(exampleDir, f) })
            ),
            metadata,
            extractorName: 'ini-extractor',
          });
        } catch (err) {
          errors.push({
            path: iniFile,
            message: `Failed to parse: ${(err as Error).message}`,
          });
        }
      }

      return { examples, errors, claimedFiles };
    },
  };
}

function collectFiles(dir: string): string[] {
  const files: string[] = [];
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const fullPath = path.join(entry.parentPath, entry.name);
    if (entry.isDirectory() && entry.name !== 'node_modules') {
      files.push(...collectFiles(fullPath));
    } else if (entry.isFile()) {
      files.push(fullPath);
    }
  }
  return files;
}

/** A file-contents parser that strips INI-style comments from .ini files. */
function createIniCommentStripper(): FileContentsParser {
  return {
    name: 'ini-comment-stripper',
    parse(context: FileParseContext): FileParseContext {
      if (!context.filePath?.endsWith('.ini')) return context;

      const stripped = context.parsed
        .split('\n')
        .filter((line) => {
          const trimmed = line.trim();
          return !trimmed.startsWith(';') && !trimmed.startsWith('#');
        })
        .join('\n');

      return { ...context, parsed: stripped };
    },
  };
}

/** Create the INI plugin. */
export function createIniPlugin(): Plugin<IniMetadata> {
  return {
    name: 'ini',
    extensions: ['.ini'],
    extractor: createIniExtractor(),
    fileContentsParsers: [createIniCommentStripper()],
  };
}

Configuration

import type { Config } from 'functional-examples';
import { createIniPlugin } from './src/ini-plugin.js';

/**
 * Configuration demonstrating a custom INI plugin.
 *
 * The INI plugin provides:
 * - An extractor that discovers `meta.ini` files
 * - A parser that strips INI comments from content
 */
const config = {
  plugins: [createIniPlugin()],
  scan: {
    include: ['src/**/*'],
    exclude: ['**/node_modules/**'],
  },
} satisfies Config;

export default config;

Building Each Component

1. Extractor

The extractor discovers examples from file candidates. It scans for a specific file pattern (e.g., meta.ini), parses metadata, collects sibling files, and returns ExtractorResult.

Key responsibilities:

  • Scan candidates for your metadata files
  • Parse metadata and validate required fields
  • Collect all files belonging to each example
  • Add files to claimedFiles to prevent conflicts
  • Return errors for recoverable issues

2. File Contents Parser

Parsers transform file content in a pipeline. Each parser receives a FileParseContext and returns a modified context:

interface FileContentsParser

The context includes:

  • raw — original file content
  • parsed — current parsed content (modified by previous parsers)
  • hunks — extracted code regions
  • metadata — example metadata
  • filePath — absolute path to the file

3. Schemas (Optional)

Contribute JSON Schema definitions for plugin options and metadata validation:

const schemas = {
  options: {
    /* JSON Schema for plugin options */
  },
  metadata: {
    /* JSON Schema for metadata fields */
  },
};

4. Validators (Optional)

Provide custom validation functions beyond what JSON Schema can express:

const validators = {
  validateOptions(_options: unknown) {
    return { success: true, errors: [] as string[] };
  },
  validateMetadata(_metadata: unknown) {
    return { success: true, errors: [] as string[] };
  },
};

5. Commands (Optional)

Add CLI commands that are loaded when your plugin is registered:

const commands = [
  {
    name: 'my-command',
    description: 'Does something useful',
    handler: async (_args: unknown) => {
      /* ... */
    },
  },
];

Plugin Lifecycle

  1. Loading — Config resolution imports and instantiates plugins
  2. Registration — Plugins register extractors, parsers, schemas, and commands
  3. Composition — Multiple plugins compose: extractors run in parallel, parsers run in sequence, schemas merge

Best Practices

  • Keep plugins focused — one extractor per metadata format
  • Use extensions to declare which file types you handle
  • Always validate required metadata fields in the extractor
  • Return errors instead of throwing for recoverable issues
  • Annotate code with region markers for documentation extraction

See also: Custom Extractors for extractor-only implementations.