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>
| Field | Purpose |
|---|---|
name | Unique identifier for the plugin |
extensions | File extensions this plugin handles |
extractor | Discovers and extracts examples from files |
fileContentsParsers | Transform file content in the parse pipeline |
schemas | JSON Schema definitions for plugin options and metadata |
validators | Validation functions for options and metadata |
commands | CLI 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
candidatesfor your metadata files - Parse metadata and validate required fields
- Collect all files belonging to each example
- Add files to
claimedFilesto 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 contentparsed— current parsed content (modified by previous parsers)hunks— extracted code regionsmetadata— example metadatafilePath— 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
- Loading — Config resolution imports and instantiates plugins
- Registration — Plugins register extractors, parsers, schemas, and commands
- 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
extensionsto 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.