stnd.build · DocumentationSTANDARD MANUALSTD-CREATING · 2026-03-15
STD-CREATING

Creating Custom Typography Plugins

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 #tags in 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 (&quot;, &lt;, 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

  1. Read existing plugins: See typography/*.js for patterns
  2. Identify a need: What typography rule is missing?
  3. Create your plugin: Follow the structure above
  4. Test thoroughly: Run against real content
  5. Document: Explain the “why” in comments
  6. 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. 🪓

Standard OS — stnd.buildSTD-CREATING · rev. 2026-03-15