Creating Custom Typography Plugins
This guide shows you how to create your own typography plugins for Standard Garden’s Press system.
🎯 Philosophy
A well-made plugin is like a well-made hand tool—simple, focused, and does one thing exceptionally well. If you can’t explain what your plugin does in one sentence, it’s trying to do too much.
🚀 Quick Start
Want to add a new typography rule? Create a single file. That’s it.
Example: Remove Markdown Comments
Goal: Strip HTML-style comments <!— … —> from markdown before rendering.
File: typography/html-comments.js
/**
* Typography Plugin: HTML Comments
*
* Removes HTML-style <!— comments —> from markdown content.
* Useful for notes with inline TODO items or private annotations.
*
* @plugin typography-html-comments
* @stage pre
*/
export const detect = () => true; // Always run
export const transform = (text) => {
// Remove <!— … —> patterns (including multiline)
return text.replace(/<!—[\s\S]*?—>/g, “”);
};
export const metadata = {
name: “HTML Comments”,
id: “typography-html-comments”,
description: “Removes HTML comments from content before rendering”,
version: “1.0.0”,
stage: “pre”, // Run before markdown parsing
priority: 5, // Run early
locale: “all”, // Works for all languages
};
Register: Add to typography/index.js
import * as HtmlComments from “./html-comments.js”;
export function initializeTypographyPlugins() {
// …
registerTypographyPlugin(“typography-html-comments”, HtmlComments);
// …
}
Done. Your plugin is now active.
📦 Plugin Structure
Every plugin exports three things:
1. detect(frontmatter, content) → boolean
Determines if this plugin should run for a given note.
// Always run
export const detect = () => true;
// Run only for French content
export const detect = (frontmatter) => frontmatter.locale === “fr”;
// Run only if content contains code blocks
export const detect = (_, content) => content.includes(“```”);
// Run based on frontmatter flag
export const detect = (frontmatter) => frontmatter.useSmartQuotes !== false;
2. transform(text, context) → string
The actual transformation function.
/**
* @param {string} text – Input text (markdown or HTML depending on stage)
* @param {Object} context – Rendering context
* @param {string} context.locale – Locale (‘en’, ‘fr’, etc.)
* @param {Object} context.frontmatter – Note frontmatter
* @param {Object} context.rules – Typography rules for locale
* @returns {string} – Transformed text
*/
export const transform = (text, context = {}) => {
const { locale = “en”, rules = {} } = context;
// Your transformation logic here
return text;
};
3. metadata object
Plugin metadata and configuration.
export const metadata = {
name: “Plugin Name”, // Human-readable name
id: “typography-plugin-id”, // Unique identifier (kebab-case)
description: “What it does”, // One-sentence description
version: “1.0.0”, // Semantic version
stage: “post”, // ‘pre’, ‘markdown’, or ‘post’
priority: 50, // Execution order (0-100, lower = first)
locale: “all”, // ‘all’, ‘en’, ‘fr’, ‘de’, etc.
};
🌊 Three Stages
Plugins run in one of three stages:
Stage 1: Pre-Processing (stage: ‘pre’)
Runs on raw markdown before parsing.
Use for:
- Stripping comments (``,
<!—…—>) - Protecting content from transformation
- Preprocessing custom syntax
Example plugins:
obsidian-comments.js– Remove ``- Content protection (URLs, emails, dates)
// Input: raw markdown
“This is text”;
// Output: cleaned markdown
“This is text”;
Stage 2: Markdown Parsing (stage: ‘markdown’)
Runs during markdown-it parsing as a markdown-it plugin.
Use for:
- Custom syntax (wikilinks, tags, etc.)
- Markdown extensions
- Token manipulation
Special: Must export markdownPlugin function instead of transform:
export function markdownPlugin(md) {
md.core.ruler.push(“my_rule”, function (state) {
// Manipulate markdown-it tokens
});
}
export const metadata = {
// …
stage: “markdown”,
};
Example plugins:
wikilinks.js– Transform[[links]]tags.js– Wrap#tagsin spans
Stage 3: Post-Processing (stage: ‘post’)
Runs on rendered HTML after markdown parsing.
Use for:
- Typography rules (quotes, dashes, spaces)
- Symbol replacement
- HTML cleanup
Example plugins:
punctuation.js–—→—quotes.js–“text”→“text”fractions.js–½→½
// Input: rendered HTML
“<p>This is a — test.</p>”;
// Output: improved HTML
“<p>This is a — test.</p>”;
🎯 Priority System
Plugins execute in priority order (0-100):
| Priority | Stage | Purpose |
|---|---|---|
| 0-10 | pre |
Critical preprocessing (comments, protection) |
| 10-30 | post |
Core typography (punctuation, quotes) |
| 30-50 | post |
Enhancement (fractions, spacing) |
| 50-80 | post |
Polish (orphans, arrows) |
| 80-100 | post |
Final touches (emojis) |
Lower priority = runs first.
// These run in order:
{
priority: 5;
} // First
{
priority: 10;
} // Second
{
priority: 50;
} // Third
{
priority: 90;
} // Last
🌍 Locale Support
Make your plugin locale-aware:
export const metadata = {
// …
locale: “fr”, // Only run for French content
};
Or handle multiple locales in the transform:
export const transform = (text, context = {}) => {
const { locale = “en” } = context;
if (locale === “fr”) {
// French-specific transformation
return text.replace(/\s+:/g, “\u2009:”); // Thin space before colon
}
// Other locales
return text;
};
export const metadata = {
locale: “all”, // Runs for all, but transforms conditionally
};
🛠️ Common Patterns
Pattern 1: Simple Text Replacement
export const transform = (text) => {
return text
.replace(/\(c\)/gi, “©”)
.replace(/\(r\)/gi, “®”)
.replace(/\(tm\)/gi, “™”);
};
Pattern 2: Conditional Replacement
export const detect = (frontmatter) => {
return frontmatter.useSmartDashes !== false; // Opt-out
};
export const transform = (text) => {
return text.replace(/—/g, “—”);
};
Pattern 3: Regex with Boundaries
export const transform = (text) => {
// Only replace fractions when surrounded by word boundaries
return text.replace(/\b1\/2\b/g, “½”);
};
Pattern 4: Skip HTML Tags
export const transform = (html) => {
// Split on HTML tags, only transform text between tags
const parts = html.split(/(<[^>]+>)/g);
return parts
.map((part) => {
if (part.startsWith(“<”)) return part; // Skip tags
return part.replace(/old/g, “new”); // Transform text
})
.join(“”);
};
Pattern 5: Use Locale Rules
import { getRulesForLocale } from “./config.js”;
export const transform = (text, context = {}) => {
const { locale = “en” } = context;
const rules = getRulesForLocale(locale);
// Use locale-specific characters
return text.replace(/—/g, rules.emDash);
};
🧪 Testing Your Plugin
Create a test file:
// test-my-plugin.mjs
import { Press } from “@stnd/press”;
const press = new Press(“Input text”);
await press.parse();
console.log(press.html);
// Verify transformation
if (press.html.includes(“expected output”)) {
console.log(“✅ Plugin works!”);
} else {
console.log(“❌ Plugin failed”);
console.log(“Output:”, press.html);
}
Run with:
node test-my-plugin.mjs
📚 Real-World Examples
Example 1: Keyboard Shortcuts
Transform keyboard shortcuts into proper notation.
// Ctrl+C → Ctrl+C (styled)
export const transform = (html) => {
return html.replace(
/\b(Ctrl|Cmd|Alt|Shift)\+([A-Z])\b/g,
“<kbd>$1</kbd>+<kbd>$2</kbd>”,
);
};
export const metadata = {
stage: “post”,
priority: 70,
};
Example 2: Math Symbols
Replace ASCII math with proper symbols.
export const transform = (text) => {
return text
.replace(/\+-/g, “±”)
.replace(/!=/g, “≠”)
.replace(/<=/g, “≤”)
.replace(/>=/g, “≥”)
.replace(/~=/g, “≈”);
};
Example 3: Conditional Formatting
Only apply to notes with specific frontmatter.
export const detect = (frontmatter) => {
return frontmatter.typography === “academic”;
};
export const transform = (html) => {
// Apply academic-specific formatting
// e.g., small caps for abbreviations
return html.replace(/\b([A-Z]{2,})\b/g, ‘<span class=“small-caps”>$1</span>’);
};
⚠️ Best Practices
DO:
- ✅ Keep plugins simple (one responsibility)
- ✅ Use descriptive IDs (
typography-french-spacing) - ✅ Document the “why” in comments
- ✅ Test with real content
- ✅ Handle edge cases (empty strings, special characters)
- ✅ Respect locale settings
- ✅ Use appropriate priority
- ✅ Be performant (avoid complex regex on large text)
DON’T:
- ❌ Transform HTML attributes (use tag-aware parsing)
- ❌ Hardcode values (use locale rules from config)
- ❌ Make plugins depend on each other
- ❌ Mutate the input (always return new string)
- ❌ Use global state
- ❌ Forget to handle HTML entities (
",<, etc.) - ❌ Run expensive operations on every note
🐛 Debugging
Enable debug logs:
DEBUG=Typography npm run dev
View active plugins:
import { getAllTypographyPlugins } from “@stnd/press/typography”;
console.log(getAllTypographyPlugins());
Test individual plugin:
import * as MyPlugin from “./typography/my-plugin.js”;
const input = “test text”;
const output = MyPlugin.transform(input, { locale: “en” });
console.log(“Input:”, input);
console.log(“Output:”, output);
🎯 Next Steps
- Read existing plugins: See
typography/*.jsfor patterns - Identify a need: What typography rule is missing?
- Create your plugin: Follow the structure above
- Test thoroughly: Run against real content
- Document: Explain the “why” in comments
- Share: Submit a PR if it’s generally useful!
Remember: A well-made plugin doesn’t apologize for its purpose. It simply serves it with quiet dignity.
Happy coding, craftsperson. 🪓