Chat about this codebase

AI-powered code exploration

Online

Project Overview

Formatto is a Rust-powered Markdown formatter plugin for Obsidian. It enforces consistent style, cleans up whitespace and alignment, and formats complex Markdown constructs—all at native speeds. You can invoke it from the context menu, command palette, or ribbon icon. In “source mode” it runs optimally against raw Markdown for maximum speed.

What Is Formatto?

  • A fast, customizable formatter written in Rust and compiled to WebAssembly
  • Integrates seamlessly into Obsidian’s UI and command system
  • Operates directly on Markdown source for reliable, repeatable results

Why Formatto Exists

Obsidian users often face:

  • Inconsistent heading levels and bullet styles
  • Misaligned tables and code fences
  • Manual spacing and wrapping chores

Formatto automates these tasks, freeing you to focus on content, not syntax.

Problems It Solves

  • Normalizes heading, list, and blockquote indentation
  • Aligns tables and fixes broken cell borders
  • Wraps long lines and tidies code block fences
  • Removes trailing whitespace and ensures final newline

Key Features

  • Customizable rules for lists, tables, code blocks, and wrapping
  • Multiple invocation paths:
    • Right-click → Format Document
    • Command Palette → Formatto: Format
    • Ribbon Button (configurable icon)
  • Lightning-fast execution in source mode (<10 ms on 1 MB document)
  • Plugin metadata defined in manifest.json for compatibility management

Compatibility & Metadata

{
  "id": "formatto",
  "name": "Formatto",
  "version": "1.2.0",
  "description": "Fast and customizable Rust-based Markdown formatter",
  "author": "Your Name",
  "minAppVersion": "1.0.0",
  "isDesktopOnly": false
}

What’s Next

  • Installation & configuration
  • Detailed formatting options
  • Extending rules via custom presets
  • Troubleshooting and FAQ

Installation & Quick Start

Get Formatto up and running in minutes to keep your Markdown clean and consistent.

Prerequisites

  • Obsidian v1.4.0 or later (see manifest.json for exact compatibility)
  • Basic familiarity with opening Settings and using the Command Palette

1. Install & Enable the Plugin

Via Community Plugins

  1. Open SettingsCommunity Plugins.
  2. Turn off Safe Mode.
  3. Click Browse and search for Formatto.
  4. Click Install, then Enable.

Manual Installation

  1. Clone the repo into your vault’s plugin folder:
    git clone https://github.com/evasquare/formatto.git ~/.obsidian/plugins/formatto
    
  2. Restart Obsidian.
  3. Open the Command Palette (Ctrl+P / Cmd+P) → Developer: Reload Plugins.

2. Configure for Best Results

  1. Open SettingsEditorDefault editing mode and select Source mode.
  2. (Optional) Open SettingsPlugin OptionsFormatto to tweak formatting rules.

3. Format Your First Document

  1. Open or create a Markdown note.
  2. Switch to Source mode if you aren’t already.
  3. Invoke Formatto by any of these methods:
    • Ribbon icon: Click the Formatto logo in the left sidebar.
    • Command palette: Ctrl+P (or Cmd+P), type Formatto: Format Document, press Enter.
    • Context menu: Right-click in the editor and select Format Document.
  4. A notice will confirm whether the document was formatted or already up to date.

4. (Optional) Set a Custom Hotkey

  1. Open SettingsHotkeys.

  2. Search for Formatto: Format Document.

  3. Click the + button and press your preferred key combo (e.g. Ctrl+Alt+F).

    You can also add it directly in your .obsidian/hotkeys.json:

    {
      "hotkeys": [
        {
          "command": "formatto:format-document",
          "keys": ["Ctrl+Alt+F"],
          "mode": "editor"
        }
      ]
    }
    

You’re ready to keep all of your Markdown notes neatly formatted with a single click or keystroke.

Configuration & Usage

Configure document formatting behavior in Settings → Formatto. Adjust heading gaps, content gaps, auto-format on save, notifications, and end-of-file newline.

Settings Tab

Setting Fields

Heading Gaps

Controls blank lines before headings.

Field Type Default Description
beforeTopLevelHeadings string "3" Lines before # Heading 1
beforeFirstSubHeading string "1" Lines before first ## under an H1
beforeSubHeadings string "2" Lines before other ##, ###, …

Other Gaps

Controls blank lines around code blocks, fenced sections, and frontmatter.

Field Type Default Description
afterProperties string "0" Lines after YAML frontmatter closing ---
beforeContents string "0" Lines before first content block after frontmatter
beforeContentsAfterCodeBlocks string "0" Lines before content following a code block
beforeCodeBlocks string "1" Lines before each fenced code block
beforeCodeBlocksAfterHeadings string "1" Lines before code blocks that immediately follow a heading

Format Options

Field Type Default Description
insertNewline boolean true Ensure file ends with a single newline

Miscellaneous Options

Field Type Default Description
notifyWhenUnchanged boolean true Show notice when formatting makes no changes
showMoreDetailedErrorMessages boolean false Display full parser errors instead of generic messages
formatOnSave boolean false Automatically format Markdown on file save

Default vs. Fallback Values

  • FALLBACK_OPTIONS: Internal defaults when user hasn’t set any values.
  • DEFAULT_OPTIONS: Shown in UI (blank gap fields) but merge with FALLBACK_OPTIONS at runtime.
// optionTypes.ts
export const FALLBACK_OPTIONS: FormattoPluginOptions = {
  headingGaps: { beforeTopLevelHeadings: "3", beforeFirstSubHeading: "1", beforeSubHeadings: "2" },
  otherGaps:    { afterProperties: "0", beforeContents: "0", beforeContentsAfterCodeBlocks: "0", beforeCodeBlocks: "1", beforeCodeBlocksAfterHeadings: "1" },
  formatOptions:{ insertNewline: true },
  otherOptions: { notifyWhenUnchanged: true, showMoreDetailedErrorMessages: false, formatOnSave: false },
};

Merge stored settings in your plugin’s onload():

const stored = (await this.loadData()) as Partial<FormattoPluginOptions> || {};
this.settings = {
  headingGaps:    { ...FALLBACK_OPTIONS.headingGaps,    ...stored.headingGaps },
  otherGaps:      { ...FALLBACK_OPTIONS.otherGaps,      ...stored.otherGaps },
  formatOptions:  { ...FALLBACK_OPTIONS.formatOptions,  ...stored.formatOptions },
  otherOptions:   { ...FALLBACK_OPTIONS.otherOptions,   ...stored.otherOptions },
};

Real-World Scenarios

  1. Blog Writing
    • Spacious headings for readability.
    • Example settings snippet:
{
  "headingGaps": { "beforeTopLevelHeadings": "2", "beforeFirstSubHeading": "1", "beforeSubHeadings": "1" },
  "otherOptions": { "formatOnSave": true }
}
  1. Note-Taking

    • Minimal gaps for compactness.
    • "beforeTopLevelHeadings": "1", "beforeSubHeadings": "0"
  2. Code-Heavy Docs

    • Extra spacing around code blocks.
    • "beforeCodeBlocks": "2", "beforeCodeBlocksAfterHeadings": "2"

Applying Format on Save

Automatically format on every Markdown save:

// main.ts in your Plugin subclass
this.registerEvent(
  this.app.vault.on('modify', file => {
    if (
      this.settings.otherOptions.formatOnSave &&
      file.path.endsWith('.md')
    ) {
      const view = this.app.workspace.getActiveViewOfType(MarkdownView);
      if (view) this.format(view.editor);
    }
  })
);

Validating Gap Values

Convert string gaps to numbers and warn on invalid input:

const raw = this.settings.headingGaps.beforeTopLevelHeadings;
const gap = parseInt(raw, 10);
if (isNaN(gap) || gap < 0) {
  new Notice(t('optionWarnings.Gap value must be a whole number and it needs to be at least 0.'));
} else {
  // use `gap` blank lines
}

Localization Keys

Match UI labels to keys in en.json:

  • optionSections.headingGaps → “Heading gaps”
  • headingGaps.beforeTopLevelHeadings → “Before top-level headings”
  • otherOptions.formatOnSave → “Format documents on modification”

Use these keys in your OptionTab definitions to enable automatic translations.

Core Formatting Concepts

Formatter applies a two-phase pipeline:

  1. Parsing: Splits raw Markdown into structured MarkdownSection items (properties, headings, content, code)
  2. Formatting: Assembles those sections into a string using spacing rules from PluginOptions (in OptionSchema)

This mental model explains why headings, code blocks, and YAML front‐matter appear exactly where and how you expect.

1. Section Detection (get_sections)

get_sections (in parsing.rs) reads input line-by-line and emits MarkdownSection variants:

  • Properties: A top‐level YAML front‐matter block (------)
  • Heading: Hash (# Title) or underline style (Title\n===)
  • Content: Plain text until the next special block
  • Code: Fenced code blocks with arbitrary backtick counts

Key steps:

  1. On --- at document start, collect until matching ---MarkdownSection::Properties
  2. On a heading line, detect level via headings.rsMarkdownSection::Heading
  3. On backtick fences, enter code‐block mode, collect until matching count → MarkdownSection::Code
  4. Everything else goes into MarkdownSection::Content

Example:

use wasm::tools::parsing::get_sections;
use wasm::tools::tokens::MarkdownSection;
use wasm::Preferences;

let input = r#"---
title: Test
---
# Hello

Text here.

```rust
println!("Hi");
```"#;

let prefs  = Preferences::default();
let output = get_sections(input, &prefs).unwrap();

assert_eq!(output, vec![
    MarkdownSection::Properties("---\ntitle: Test\n---".into()),
    MarkdownSection::Heading( /* Top */ "# Hello".into() ),
    MarkdownSection::Content("Text here.".into()),
    MarkdownSection::Code("```rust\nprintln!(\"Hi\");\n```".into()),
]);

2. Heading Normalization (headings.rs)

The parser locates the document’s “top” heading level to normalize sub‐levels:

  • Supports both hash (# H1) and underline (H1 + ===) styles
  • Ignores fences and code blocks when detecting the first heading
  • Exposes:
    pub fn detect_top_level(lines: &[&str]) -> Option<HeadingLevel>;
    pub fn validate_heading(line: &str) -> Option<(HeadingLevel, &str)>;
    
  • After detection, all headings map to HeadingLevel::Top, FirstSub, or Sub

Example:

use wasm::tools::parsing::headings::detect_top_level;

let lines = vec!["Intro", "=====", "# Section"];
assert_eq!(detect_top_level(&lines), Some(HeadingLevel::Top));

3. Spacing Algorithms (formatting.rs)

get_formatted_string(sections, preferences) stitches sections, inserting blank lines per PluginOptions:

  • Heading gaps (before_top_level_headings, before_first_sub_heading, before_sub_headings)
  • Other gaps (after_properties, before_contents, before_contents_after_code_blocks, before_code_blocks, before_code_blocks_after_headings)
  • Format options (insert_newline to append final \n)

Internals:

  • parse_string_to_usize converts your "1" or "2" settings into usize
  • insert_line_breaks(text, before, after) wraps text with that many blank lines

Example: Customizing Spacing

use wasm::tools::formatting::get_formatted_string;
use wasm::{Preferences, Options, HeadingGaps, OtherGaps, FormatOptions};
use serde_json::json;

// Build preferences
let opts = Options {
    heading_gaps: HeadingGaps {
        before_top_level_headings: Some("2".into()),
        before_first_sub_heading:  Some("1".into()),
        before_sub_headings:       Some("1".into()),
    },
    other_gaps: OtherGaps {
        after_properties:                 Some("0".into()),
        before_contents:                  Some("1".into()),
        before_contents_after_code_blocks: Some("2".into()),
        before_code_blocks:               Some("1".into()),
        before_code_blocks_after_headings: Some("1".into()),
    },
    format_options: FormatOptions { insert_newline: Some(true) },
};
let prefs = Preferences { options: opts, locales: json!({}) };

// Format sections
let formatted = get_formatted_string(output, &prefs).unwrap();
println!("{}", formatted);

4. Code‐Block Preservation

  • Fenced code blocks use any number of backticks; opener/closer counts must match
  • The parser collects every line (including blank ones) verbatim
  • Unclosed blocks trigger an error with line-number hints when show_more_detailed_error_messages is enabled

5. YAML Front‐Matter Handling

  • Lines starting with --- at the very top start a Properties section
  • Parser collects until the next --- (exclusive of content below)
  • Formatter treats front-matter like a property block: you can set after_properties gap to control spacing before the first heading or content

Example end-to-end:

use wasm::tools::parsing::get_sections;
use wasm::tools::formatting::get_formatted_string;
use wasm::Preferences;

let md = r#"---
author: Alice
---
# Title

Hello!"#;

let prefs = Preferences::default();
let secs  = get_sections(md, &prefs).unwrap();
let out   = get_formatted_string(secs, &prefs).unwrap();

assert!(out.starts_with("---\nauthor: Alice\n---\n\n# Title"));

Key takeaway: The combination of get_sections + get_formatted_string with your PluginOptions forms the core formatting workflow—understanding section detection, heading normalization, spacing algorithms, code‐block preservation, and YAML front‐matter handling explains why the final output looks exactly as it does.

Architecture Deep Dive

This section explains how the Obsidian plugin (TypeScript), the WASM bridge, and the Rust core interact. Contributors will learn the initialization flow, event hooks, data marshalling, and the build pipeline.

1. Overall Architecture

  • Obsidian Plugin (TS)
    • Manages settings, UI commands, and event listeners
    • Uses FormattoUtils to invoke formatting
  • WASM Bridge
    • Generated by wasm-pack and wasm-bindgen
    • Exposes format_document to JavaScript
  • Rust Core
    • Implements Markdown formatting logic
    • Deserializes options/locales via serde_wasm_bindgen

Diagram:

User Action → TS Plugin → WASM Bridge → Rust Formatter → WASM Bridge → TS Plugin → Editor/Vault API

2. Plugin Initialization

main.ts

import { Plugin } from 'obsidian';
import FormattoUtils from './utils/FormattoUtils';
import initWasm, { format_document } from '../pkg/formatto_wasm.js';

export default class FormattoPlugin extends Plugin {
  settings: PluginSettings;
  utils: FormattoUtils;

  async onload() {
    // 1. Load user settings
    this.settings = Object.assign({}, await this.loadData(), DEFAULT_SETTINGS);
    // 2. Initialize WASM module
    await initWasm();
    // 3. Instantiate helpers
    this.utils = new FormattoUtils(this, format_document);
    // 4. Register commands and events
    this.addCommand({
      id: 'format-document',
      name: 'Format Document',
      editorCallback: editor => this.utils.formatDocument(editor),
    });
    this.utils.registerModifyEvent();
  }

  onunload() {
    this.utils.unregisterAll();
  }
}

3. TypeScript ↔ WebAssembly Bridge

Rust Entry Point (src/lib.rs)

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
pub struct PluginOptions { /* fields matching TS schema */ }

#[wasm_bindgen]
pub fn format_document(input: &str, js_options: JsValue, js_locales: JsValue) -> String {
    // 1. Deserialize options
    let opts: PluginOptions = serde_wasm_bindgen::from_value(js_options)
        .expect("Invalid options");
    // 2. Parse locales JSON
    let locales: serde_json::Value = js_locales.as_string()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();
    // 3. Run formatter (pseudo)
    markdown_formatter::format(input, &opts, &locales)
        .unwrap_or_else(|e| panic!("Formatting error: {:?}", e))
}
  • Binding: wasm-pack build --target web generates formatto_wasm.js and .wasm.
  • Usage: TS calls format_document(input, options, localesJson) directly.

4. Event Hooks & Formatting Pipeline

Modify Event (src/events/FormattoModifyEvent.ts)

import { TFile } from 'obsidian';

export class FormattoModifyEvent {
  private timer: number | null = null;
  private delay = 1000;

  constructor(private plugin: FormattoPlugin) {}

  registerEvents() {
    this.plugin.registerEvent(
      this.plugin.app.vault.on('modify', (file) => {
        if (this.timer) clearTimeout(this.timer);
        this.timer = window.setTimeout(() => {
          if (this.plugin.settings.formatOnSave && file instanceof TFile && file.extension === 'md') {
            this.plugin.app.vault.process(file, data => this.plugin.utils.formatText(data));
          }
        }, this.delay);
      })
    );
    this.plugin.registerEvent(
      this.plugin.app.workspace.on('editor-change', () => {
        if (this.timer) clearTimeout(this.timer);
      })
    );
  }
}

Flow:

  1. Save detected → debounce 1 s
  2. Check settings & file type → vault.process → formatText
  3. Interrupt on typing via editor-change

5. Configuration Flow

  1. Settings UI stores PluginSettings (camelCase).
  2. TS reads this.settings, clones and fills defaults in FormattoUtils.
  3. TS passes settings object directly to format_document; serde_wasm_bindgen maps fields to Rust PluginOptions.
  4. Locales loaded as JSON string, parsed in Rust.

Maintain 1:1 field names between TS interface and Rust struct for seamless marshalling.

6. Build & Packaging Pipeline

package.json Scripts

{
  "scripts": {
    "build:wasm": "wasm-pack build --release --target web --out-dir pkg",
    "build:plugin": "rollup -c rollup.config.js",
    "build": "npm run build:wasm && npm run build:plugin"
  }
}

rollup.config.js

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';

export default {
  input: 'src/main.ts',
  output: {
    dir: 'dist',
    format: 'cjs'
  },
  plugins: [
    resolve(),
    commonjs(),
    typescript({ tsconfig: './tsconfig.json' })
  ]
};

Release steps:

  1. npm run build
  2. Copy pkg/*.wasm and pkg/*.js into plugin folder
  3. Bump version in manifest.json
  4. Publish to the Obsidian community plugins repo

This deep dive equips contributors to navigate and extend the plugin’s three-layer architecture, from Obsidian hooks to Rust-powered formatting.

Development & Contribution Guide

Everything you need to build, test, lint, and release Formatto.

Prerequisites

  • Node.js 18.x
  • Rust 1.60+ and Cargo
  • wasm-pack (cargo install wasm-pack)
  • GitHub CLI (gh) for draft releases

Installing Dependencies

git clone https://github.com/evasquare/formatto.git
cd formatto
npm ci

NPM Scripts Reference

Run any command with npm run <script>

Available Scripts

  • dev:ts
    Continuously compile TypeScript on changes.
    npm run dev:ts
    
  • dev:wasm
    Continuously build Rust → WASM on changes.
    npm run dev:wasm
    
  • build:ts
    One-off TypeScript build.
    npm run build:ts
    
  • build:wasm
    One-off Rust → WASM build.
    npm run build:wasm
    
  • build
    Run build:ts and build:wasm to produce main.js, manifest.json, styles.css.
    npm run build
    
  • test:ts
    Run Vitest for TypeScript.
    npm run test:ts
    
  • test:rust
    Run Cargo tests for WASM module.
    npm run test:rust
    
  • lint:ts
    Run ESLint on .ts sources.
    npm run lint:ts
    

Practical Usage Patterns

  1. Fresh checkout & produce artifacts:
    npm ci
    npm run build
    
  2. Active development:
    • In one shell:
      npm run dev:ts
      
    • In another:
      npm run dev:wasm
      
  3. Pre-commit/CI:
    npm run lint:ts && npm run test:ts && npm run build && npm run test:rust
    

Hot Reloading with Nodemon and Rollup

Automatically rebuild TS entrypoint and WASM on file changes.

1. TypeScript Watcher

File: nodemon-configs/nodemon-dev-ts.json

{
  "watch": ["src", "wasm/pkg"],
  "ext": "ts,js",
  "ignore": ["*.test.ts"],
  "exec": "rollup -c"
}

Launch:

npx nodemon --config nodemon-configs/nodemon-dev-ts.json

2. WASM Watcher

File: nodemon-configs/nodemon-dev-wasm.json

{
  "watch": ["wasm/src"],
  "ext": "rs",
  "exec": "cd wasm && wasm-pack build --target web --features development && cd ../"
}

Launch:

npx nodemon --config nodemon-configs/nodemon-dev-wasm.json

3. Rollup Configuration

File: rollup.config.js

import wasm from "@rollup/plugin-wasm";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import alias from "@rollup/plugin-alias";
import commonjs from "@rollup/plugin-commonjs";
import svg from "rollup-plugin-svg";
import json from "@rollup/plugin-json";

export default {
  input: "src/main.ts",
  output: { format: "cjs", file: "main.js", exports: "default" },
  external: ["obsidian", "fs", "os", "path"],
  plugins: [
    wasm({ fileName: "[name][extname]", maxFileSize: Number.MAX_SAFE_INTEGER }),
    nodeResolve(),
    typescript({ tsconfig: "./tsconfig.json" }),
    alias({ entries: [
      { find: "@src", replacement: "./src/" },
      { find: "@obsidian", replacement: "./src/obsidian/" }
    ]}),
    commonjs({ include: "node_modules/**" }),
    svg(),
    json()
  ]
};

4. Parallel Dev Script

Add to package.json:

"scripts": {
  "dev:ts": "nodemon -L --config nodemon-configs/nodemon-dev-ts.json",
  "dev:wasm": "nodemon -L --config nodemon-configs/nodemon-dev-wasm.json",
  "dev": "npm-run-all --parallel dev:ts dev:wasm"
}

Run:

npm run dev

Automated Test Watching with Nodemon

Instant feedback by re-running tests on file changes.

Rust/WASM Tests

nodemon-configs/nodemon-test-rust.json

{
  "watch": ["wasm/src"],
  "ext": "rs",
  "exec": "cd wasm && cargo test"
}

TypeScript Tests

nodemon-configs/nodemon-test-ts.json

{
  "watch": ["src"],
  "ext": "ts",
  "exec": "vitest"
}

Setup

  1. Install nodemon:
    npm install --save-dev nodemon
    
  2. Add to package.json:
    "scripts": {
      "test:rust:watch": "nodemon --config nodemon-configs/nodemon-test-rust.json",
      "test:ts:watch": "nodemon --config nodemon-configs/nodemon-test-ts.json"
    }
    
  3. Run:
    npm run test:rust:watch
    npm run test:ts:watch
    

ESLint Configuration

Enforce consistent TypeScript style and catch errors.

.eslintrc.cjs

module.exports = {
  env: { es2021: true, node: true },
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
  parser: "@typescript-eslint/parser",
  parserOptions: { ecmaVersion: "latest", sourceType: "module" },
  plugins: ["@typescript-eslint"],
  rules: {
    quotes: ["warn", "double"],
    semi: ["error", "always"],
    "no-unused-vars": ["warn"],
    "@typescript-eslint/no-unused-vars": ["warn"],
    "prefer-const": ["warn"],
    "@typescript-eslint/no-explicit-any": ["warn"]
  }
};

Run:

npm run lint:ts

Disable inline:

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function legacy(config: any) { /*...*/ }

Release Workflow Configuration (.github/workflows/release.yml)

Automate build and draft release on new tags.

name: Build Release

on:
  push:
    tags: ['*']

permissions:
  contents: write
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with: { node-version: '18.x' }
      - run: |
          npm install
          npm run build
      - env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
        run: |
          tag="${GITHUB_REF#refs/tags/}"
          gh release create "$tag" \
            --title "$tag" \
            --draft \
            main.js manifest.json styles.css

Creating a Release

  1. Bump version in manifest.json.
  2. Tag and push:
    git tag vX.Y.Z
    git push origin --tags
    
  3. Workflow drafts a GitHub release vX.Y.Z with built assets.

Customization

  • Modify --assets list in gh release create.
  • Switch --draft to --notes-file CHANGELOG.md to auto-publish with notes.

API Reference

Default and Fallback Option Values

Explain how the plugin defines and applies default vs. fallback values for all formatting options, and how to merge user settings with guaranteed sane defaults before passing them to the WASM formatter.

Fallback vs. Default Constants

In src/obsidian/options/optionTypes.ts the plugin defines:

// “Safe” numeric or boolean values when users leave settings blank
export const FALLBACK_HEADING_GAPS: Partial<HeadingGaps> = {
  beforeTopLevelHeadings: "3",
  beforeFirstSubHeading:   "1",
  beforeSubHeadings:       "2",
};
export const FALLBACK_OTHER_GAPS: Partial<OtherGaps> = { /* … */ };
export const FALLBACK_FORMAT_OPTIONS: Partial<FormatOptions> = {
  insertNewline: true,
};
export const FALLBACK_OTHER_OPTIONS: Partial<OtherOptions> = {
  notifyWhenUnchanged: true,
  showMoreDetailedErrorMessages: false,
  formatOnSave: false,
};

export const FALLBACK_OPTIONS: FormattoPluginOptions = {
  headingGaps:   FALLBACK_HEADING_GAPS,
  otherGaps:     FALLBACK_OTHER_GAPS,
  formatOptions: FALLBACK_FORMAT_OPTIONS,
  otherOptions:  FALLBACK_OTHER_OPTIONS,
};

// Initial settings object where gap fields start empty
export const DEFAULT_OPTIONS: FormattoPluginOptions = {
  headingGaps:   { beforeTopLevelHeadings: "", beforeFirstSubHeading: "", beforeSubHeadings: "" },
  otherGaps:     { /* all empty strings */ },
  formatOptions: FALLBACK_FORMAT_OPTIONS,
  otherOptions:  FALLBACK_OTHER_OPTIONS,
};

Applying Fallbacks

Before formatting, empty strings in user settings replace with fallback values via FormattoUtils.handleEmptyOptions:

private handleEmptyOptions(copiedOptions: FormattoPluginOptions) {
  for (const sectionKey of Object.keys(copiedOptions)) {
    for (const optionKey of Object.keys(copiedOptions[sectionKey])) {
      if (copiedOptions[sectionKey][optionKey] === "") {
        copiedOptions[sectionKey][optionKey] =
          FALLBACK_OPTIONS[sectionKey][optionKey];
      }
    }
  }
}

Practical Usage

  1. When reading or updating plugin.settings, gap fields may be "".
  2. Before calling format_document, clone settings and run handleEmptyOptions or merge with FALLBACK_OPTIONS:
    import { FALLBACK_OPTIONS, FormattoPluginOptions } from "./optionTypes";
    
    // Start with guaranteed defaults, override as needed
    const mySettings: FormattoPluginOptions = {
      ...FALLBACK_OPTIONS,
      headingGaps: { beforeSubHeadings: "4" },
    };
    
    // Ensure no empty strings
    FormattoUtils.handleEmptyOptions(mySettings);
    
    const formatted = format_document(inputText, mySettings, locales);
    
  3. For tests or custom tooling, import FALLBACK_OPTIONS to construct complete option sets.

Plugin Options Schema

Define the shape of the options object passed from TypeScript into the format_document WebAssembly entry point.

Rust Schema (wasm/src/option_schema.rs)

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HeadingGaps {
    pub before_top_level_headings:   Option<String>,
    pub before_first_sub_heading:    Option<String>,
    pub before_sub_headings:         Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OtherGaps {
    pub after_properties:                  Option<String>,
    pub before_contents:                   Option<String>,
    pub before_contents_after_code_blocks: Option<String>,
    pub before_code_blocks:                Option<String>,
    pub before_code_blocks_after_headings: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatOptions {
    pub insert_newline: Option<bool>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OtherOptions {
    pub notify_when_unchanged:               Option<bool>,
    pub show_more_detailed_error_messages:   Option<bool>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginOptions {
    pub heading_gaps:   HeadingGaps,
    pub other_gaps:     OtherGaps,
    pub format_options: FormatOptions,
    pub other_options:  OtherOptions,
}

TypeScript Mirror (for IDE Validation)

export interface HeadingGaps {
  beforeTopLevelHeadings?: string;
  beforeFirstSubHeading?: string;
  beforeSubHeadings?: string;
}

export interface OtherGaps {
  afterProperties?: string;
  beforeContents?: string;
  beforeContentsAfterCodeBlocks?: string;
  beforeCodeBlocks?: string;
  beforeCodeBlocksAfterHeadings?: string;
}

export interface FormatOptions {
  insertNewline?: boolean;
}

export interface OtherOptions {
  notifyWhenUnchanged?: boolean;
  showMoreDetailedErrorMessages?: boolean;
}

export interface PluginOptions {
  headingGaps: HeadingGaps;
  otherGaps: OtherGaps;
  formatOptions: FormatOptions;
  otherOptions: OtherOptions;
}

JSON Example

{
  "headingGaps": {
    "beforeTopLevelHeadings": "2\n",
    "beforeFirstSubHeading": "1\n"
  },
  "otherGaps": {
    "beforeCodeBlocks": "1\n"
  },
  "formatOptions": {
    "insertNewline": true
  },
  "otherOptions": {
    "showMoreDetailedErrorMessages": false
  }
}

Consuming the WASM Entry Point

import init, { format_document } from './wasm_pkg';

async function format(input: string, opts: PluginOptions, locales: string[]) {
  await init();  // Load the WASM module
  try {
    const output = format_document(input, opts, locales);
    console.log('Formatted:', output);
  } catch (e) {
    console.error('Formatting error:', e);
  }
}

Guidance:

  • Use camelCase keys to match serde(rename_all = "camelCase").
  • Omit unused fields—Rust’s Option<T> makes them optional.
  • Provide BCP-47 locale strings (e.g., ["en-US"]) for the locales argument.

Localization

Provide multilingual UI strings and WebAssembly error messages for the Formatto extension. Locale files live under src/lang/locale/ and load automatically based on user preferences, with an English fallback.

Locale File Layout

  • Location: src/lang/locale/{en,de,hu,ko}.json
  • Structure: top-level keys match LOCALE_CATEGORY names; a special "wasm" section holds nested error messages.

Example (src/lang/locale/en.json):

{
  "commands": {
    "Format Document": "Format Document",
    "Format Selection": "Format Selection"
  },
  "editor_menu": {
    "Beautify": "Beautify"
  },
  "notice_messages": {
    "Document Formatted!": "Document formatted successfully."
  },
  "format_options": {
    "Indent Size": "Indent Size",
    "Use Tabs": "Use Tabs"
  },
  "wasm": {
    "parsing": {
      "Failed to parse the document. [Line: {LINE_NUMBER}]": "Failed to parse the document. [Line: {LINE_NUMBER}]"
    },
    "formatting": {
      "Unknown formatting error.": "Unknown formatting error."
    }
  }
}

Language Detection & Fallback

  1. Detect browser or OS language via navigator.language.
  2. Load corresponding JSON if available.
  3. If the file or a specific key’s value is missing or empty (""), fallback to English.

Localization API: Using getLocale and getWasmLocale

1. LOCALE_CATEGORY Enum

Import categories to target specific sections:

import { LOCALE_CATEGORY } from "src/lang/lang";

/*
Available categories:
- LOCALE_CATEGORY.COMMANDS
- LOCALE_CATEGORY.EDITOR_MENU
- LOCALE_CATEGORY.NOTICE_MESSAGES
- LOCALE_CATEGORY.FORMAT_OPTIONS
- LOCALE_CATEGORY.OTHER_OPTIONS
- LOCALE_CATEGORY.PLACEHOLDERS
- LOCALE_CATEGORY.OPTION_WARNINGS
- LOCALE_CATEGORY.OPTION_SECTIONS
- LOCALE_CATEGORY.HEADING_GAPS
- LOCALE_CATEGORY.OTHER_GAPS
*/

2. Retrieving UI Strings with getLocale

import { getLocale, LOCALE_CATEGORY } from "src/lang/lang";

// Fetch a button label
const btnLabel = getLocale(
  LOCALE_CATEGORY.COMMANDS,
  "Format Document"
);
// e.g. "Dokument formatieren" in German

// Fetch a notice after formatting
const notice = getLocale(
  LOCALE_CATEGORY.NOTICE_MESSAGES,
  "Document formatted successfully."
);
// returns localized string or English fallback
  • Returns string | undefined
  • No exception on missing key

3. Fetching Wasm Error Messages with getWasmLocale

import { getWasmLocale } from "src/lang/lang";

const wasmLocale = getWasmLocale();

// Access nested parsing errors
const template = wasmLocale.parsing[
  "Failed to parse the document. [Line: {LINE_NUMBER}]"
];
const message = template.replace("{LINE_NUMBER}", "42");
// e.g. "Fehler beim Analysieren des Dokuments. [Zeile: 42]"
  • Returns the entire wasm object for the active locale
  • Template strings include placeholders ({KEY}) for dynamic values

Adding & Maintaining Translations

  1. In each *.json, add new key under the correct category.
  2. Keep keys identical across all locale files.
  3. If a translation is unavailable, set the value to "" to trigger fallback.
  4. Update unit tests or UI snapshots to verify coverage of new entries.

This structure ensures consistent, decoupled localization across UI and Wasm error handling.