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
- Open Settings → Community Plugins.
- Turn off Safe Mode.
- Click Browse and search for Formatto.
- Click Install, then Enable.
Manual Installation
- Clone the repo into your vault’s plugin folder:
git clone https://github.com/evasquare/formatto.git ~/.obsidian/plugins/formatto
- Restart Obsidian.
- Open the Command Palette (Ctrl+P / Cmd+P) → Developer: Reload Plugins.
2. Configure for Best Results
- Open Settings → Editor → Default editing mode and select Source mode.
- (Optional) Open Settings → Plugin Options → Formatto to tweak formatting rules.
3. Format Your First Document
- Open or create a Markdown note.
- Switch to Source mode if you aren’t already.
- 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.
- A notice will confirm whether the document was formatted or already up to date.
4. (Optional) Set a Custom Hotkey
Open Settings → Hotkeys.
Search for Formatto: Format Document.
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.
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
- Blog Writing
- Spacious headings for readability.
- Example settings snippet:
{
"headingGaps": { "beforeTopLevelHeadings": "2", "beforeFirstSubHeading": "1", "beforeSubHeadings": "1" },
"otherOptions": { "formatOnSave": true }
}
Note-Taking
- Minimal gaps for compactness.
"beforeTopLevelHeadings": "1"
,"beforeSubHeadings": "0"
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:
- Parsing: Splits raw Markdown into structured
MarkdownSection
items (properties, headings, content, code) - Formatting: Assembles those sections into a string using spacing rules from
PluginOptions
(inOptionSchema
)
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:
- On
---
at document start, collect until matching---
→MarkdownSection::Properties
- On a heading line, detect level via
headings.rs
→MarkdownSection::Heading
- On backtick fences, enter code‐block mode, collect until matching count →
MarkdownSection::Code
- 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
, orSub
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 intousize
insert_line_breaks(text, before, after)
wrapstext
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 aProperties
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
andwasm-bindgen
- Exposes
format_document
to JavaScript
- Generated by
- 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
generatesformatto_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:
- Save detected → debounce 1 s
- Check settings & file type → vault.process →
formatText
- Interrupt on typing via
editor-change
5. Configuration Flow
- Settings UI stores
PluginSettings
(camelCase). - TS reads
this.settings
, clones and fills defaults inFormattoUtils
. - TS passes settings object directly to
format_document
;serde_wasm_bindgen
maps fields to RustPluginOptions
. - 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:
npm run build
- Copy
pkg/*.wasm
andpkg/*.js
into plugin folder - Bump version in
manifest.json
- 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
Runbuild:ts
andbuild:wasm
to producemain.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
- Fresh checkout & produce artifacts:
npm ci npm run build
- Active development:
- In one shell:
npm run dev:ts
- In another:
npm run dev:wasm
- In one shell:
- 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
- Install nodemon:
npm install --save-dev nodemon
- 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" }
- 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
- Bump version in
manifest.json
. - Tag and push:
git tag vX.Y.Z git push origin --tags
- Workflow drafts a GitHub release
vX.Y.Z
with built assets.
Customization
- Modify
--assets
list ingh 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
- When reading or updating
plugin.settings
, gap fields may be""
. - Before calling
format_document
, clone settings and runhandleEmptyOptions
or merge withFALLBACK_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);
- 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 thelocales
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
- Detect browser or OS language via
navigator.language
. - Load corresponding JSON if available.
- 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
- In each
*.json
, add new key under the correct category. - Keep keys identical across all locale files.
- If a translation is unavailable, set the value to
""
to trigger fallback. - Update unit tests or UI snapshots to verify coverage of new entries.
This structure ensures consistent, decoupled localization across UI and Wasm error handling.