- 1. Overview
- 2. TypeScript Standards
- 3. CSS Standards
- 4. Obsidian-Specific Guidelines
- 5. Obsidian UI Guidelines
- 6. Linting Commands
- 7. Common Issues and Solutions
- 8. Shared Utilities and Factories
This document defines coding standards for Draft Bench to ensure consistency and maintainability. These standards are enforced by ESLint (TypeScript) and Stylelint (CSS).
Key Principles:
- Write code that passes linting before committing
- Follow Obsidian API best practices
- Maintain consistent naming across the codebase
- Prioritize readability and type safety
- Files: Use kebab-case:
scene-note-writer.ts,project-graph.ts - Directories: Use kebab-case:
src/core/,src/ui/
| Type | Convention | Example |
|---|---|---|
| Interfaces | PascalCase | SceneData, DraftBenchSettings |
| Classes | PascalCase | ControlCenterModal, ProjectPicker |
| Functions | camelCase | createSceneNote(), loadProject() |
| Variables | camelCase | frontmatter, sceneCount |
| Constants | SCREAMING_SNAKE_CASE | DEFAULT_SETTINGS, DB_PROPERTY_PREFIX |
| Type Parameters | Single uppercase letter or PascalCase | T, TNode, SceneType |
IMPORTANT: Use camelCase for all settings properties, NOT Sentence Case.
// ✅ CORRECT
export interface DraftBenchSettings {
defaultProjectFolder: string;
templatesFolder: string;
defaultStatus: string;
showWordCounts: boolean;
}
// ❌ WRONG - Do NOT use Sentence Case or spaces
export interface DraftBenchSettings {
"Default Project Folder": string; // Never do this!
default_status: string; // Avoid snake_case in TS
}Settings UI Display:
- Use
.setName()for user-facing labels with sentence case (per Obsidian style guide) - Use
.setDesc()for descriptions in sentence case
// ✅ CORRECT - Sentence case for labels
new Setting(containerEl)
.setName('Default project folder') // Sentence case (lowercase after first word)
.setDesc('Where new projects are created') // Sentence case
.addText(text => text
.setValue(this.plugin.settings.defaultProjectFolder)); // camelCase property
// ❌ WRONG - Title Case
new Setting(containerEl)
.setName('Default Project Folder') // Title Case - Don't do this!
.setDesc('Where new projects are created')Reference: Obsidian Style Guide - Sentence case
All plugin-managed frontmatter keys are namespaced with the dbench- prefix:
// ✅ CORRECT - dbench- prefix on plugin-managed properties
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm['dbench-type'] = 'scene';
fm['dbench-project'] = projectName;
fm['dbench-order'] = nextOrder;
fm['dbench-status'] = 'draft';
});
// ❌ WRONG - Bare keys collide with user/other-plugin properties
fm.type = 'scene';
fm.project = projectName;The dbench- namespace is enforced by convention, not by ESLint. Reviewing diffs for property names is part of code review.
- Indentation: Use tabs
- Line Length: Aim for 100 characters maximum
- Quotes: Prefer single quotes for strings
- Semicolons: Always use semicolons
// ✅ Prefer arrow functions for callbacks
const handleClick = (event: MouseEvent): void => {
// ...
};
// ✅ Use async/await syntax
async function createSceneNote(
app: App,
scene: SceneData
): Promise<TFile> {
// ...
}
// ✅ Document complex functions with JSDoc
/**
* Create a scene note with YAML frontmatter
*
* @param app - Obsidian app instance
* @param scene - Scene data
* @returns The created TFile
*/
export async function createSceneNote(/* ... */) {
// ...
}// ✅ Group imports: external -> Obsidian -> internal
import { App, TFile, normalizePath } from 'obsidian';
import { buildSceneFrontmatter } from './frontmatter';
import { getLogger } from './logging';// ❌ AVOID
const frontmatter: Record<string, any> = {};
// ✅ PREFER - Be specific
interface SceneFrontmatter {
'dbench-type': 'scene';
'dbench-project': string;
'dbench-order': number;
'dbench-status'?: string;
}
const frontmatter: SceneFrontmatter = {
'dbench-type': 'scene',
'dbench-project': projectName,
'dbench-order': order
};
// ✅ ACCEPTABLE - For truly dynamic data, use unknown
const rawData: unknown = JSON.parse(content);ESLint Rule: @typescript-eslint/no-explicit-any: "error"
If you MUST use any, add a comment explaining why:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynamicData: Record<string, any> = {}; // Needed for arbitrary user-provided frontmatter// ✅ Always annotate function parameters and return types
function calculateWordCount(text: string): number {
// ...
}
// ✅ Annotate complex object literals
const config: CompilePreset = {
name: 'Manuscript',
includeStatuses: ['draft', 'revision', 'final'],
separator: '\n\n* * *\n\n'
};// ✅ CORRECT - Use const for values that don't change
const projectName = file.parent?.name;
const scenes = app.vault.getMarkdownFiles();
// ✅ CORRECT - Use let only when reassigning
let nextOrder = 0;
nextOrder = computeNextOrder(siblings);
// ❌ WRONG - Never use var
var fileName = 'test.md'; // ESLint error: no-varESLint Rules:
prefer-const: "error"- Use const when variable is never reassignedno-var: "error"- Never use var
// ✅ CORRECT - Prefix unused params with _
function processScene(
scene: SceneData,
_index: number, // Unused parameter
_array: SceneData[] // Unused parameter
): void {
console.log(scene.title);
}
// ✅ CORRECT - Use destructuring to omit unused values
const { title, order } = scene; // Don't destructure unneeded fields
// ❌ WRONG - Don't declare unused variables
const buffer = createBuffer(); // ESLint error if never usedESLint Rule:
"@typescript-eslint/no-unused-vars": [
"error",
{
"args": "none",
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
]// ❌ WRONG - Don't leave commented-out code
// const oldValue = scene.title;
// console.log(oldValue);
// ✅ CORRECT - Remove or use version control for historyvoid in front of a promise-returning expression tells TypeScript "I am deliberately not awaiting this." Two main uses come up in this codebase:
// Inside a non-async event-handler body (vault callbacks, etc.)
void this.onModify(file).catch((err) => {
console.error('[DraftBench] linker handleModify failed:', err);
});
// At a leaf call site whose enclosing function is non-async by design
void plugin.saveSettings();When void promise is the right tool:
- The enclosing function is synchronous (Obsidian event listeners; UI event handlers) and
awaiting the promise would require restructuring the caller chain unnecessarily. - The promise is genuinely fire-and-forget: failure doesn't change anything the user is about to do.
- Errors are handled inside the awaited function (logging, surfacing a Notice, etc.) OR explicitly chained via
.catch(handler)at the call site. Don't swallow.
When void promise is wrong:
- The user just clicked something and expects the operation to succeed. If it silently fails, they have no signal. Surface the error.
- The enclosing function COULD be async. Prefer
awaitovervoid. - Inside a try/catch where the surrounding logic depends on the promise resolving —
voidlets the function return before the work finishes, defeating the catch.
Pattern guidance, not a refactor obligation. Existing void this.X() and void (...) call sites have been chosen deliberately during incremental work; only rewrite them when one of the "wrong" conditions above clearly applies. A future runAsync(promise, errorContext) helper that centralizes the logging + Notice plumbing is a possible follow-up, but isn't a current obligation.
All CSS classes MUST follow BEM (Block__Element--Modifier) with project prefix:
Pattern: (dt|draft-bench)-[block](__[element])?(--[modifier])?
/* ✅ CORRECT - Block */
.dbench-modal-container { }
.draft-bench-card { }
/* ✅ CORRECT - Block + Element */
.dbench-card__header { }
.draft-bench-card__title { }
.dbench-nav-item__icon { }
/* ✅ CORRECT - Block + Modifier */
.dbench-btn--primary { }
.draft-bench-nav-item--active { }
/* ✅ CORRECT - Block + Element + Modifier */
.dbench-nav-item__icon--disabled { }
/* ❌ WRONG - Missing prefix */
.modal-container { } /* Stylelint error */
/* ❌ WRONG - Camel case */
.dtModalContainer { } /* Stylelint error */
/* ❌ WRONG - Sentence case or spaces */
.dbench-modal container { } /* Stylelint error */Allowed Prefixes:
dbench-(Draft Bench) - Short prefix, preferred for most casesdraft-bench-- Long form, use when collision risk warrants explicitness
| Component | Class Name |
|---|---|
| Modal container | .dbench-modal-container |
| Card header | .dbench-card__header |
| Primary button | .dbench-btn--primary |
| Active nav item | .dbench-nav-item--active |
| Scene picker | .dbench-scene-picker |
| Manuscript list | .dbench-manuscript-list |
| Compile preset row | .dbench-compile-preset-row |
Custom properties (CSS variables) MUST use kebab-case with prefix:
Pattern: --dbench-[name]
/* ✅ CORRECT - Draft Bench variables */
--dbench-scene-card-width: 240px;
--dbench-spacing-horizontal: 16px;
--dbench-status-color-draft: #888;
/* ❌ WRONG - Missing prefix */
--modal-width: 800px; /* Stylelint error */
/* ❌ WRONG - Camel case */
--dtModalWidth: 800px; /* Stylelint error */
/* ❌ WRONG - Defining Obsidian variables in our CSS */
--background-primary: #fff; /* Only USE, never DEFINE */Exception: You can USE Obsidian's built-in CSS variables:
/* ✅ CORRECT - Using Obsidian variables */
.dbench-modal {
background: var(--background-primary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
}/* ✅ CORRECT - Lowercase everything */
.dbench-button {
color: #3498db;
font-family: sans-serif;
}
/* ✅ CORRECT - Double quotes for strings */
.dbench-icon::before {
content: "->";
}
/* ✅ CORRECT - Shorthand hex colors */
color: #fff; /* Not #ffffff */
/* ✅ CORRECT - Zero values don't need units */
margin: 0; /* Not 0px */
/* ❌ WRONG - Redundant values in shorthand */
padding: 0 24px 24px 24px; /* Use: padding: 0 24px 24px; *//* ✅ CORRECT - Empty line before declarations */
.dbench-card {
padding: 16px;
background: var(--background-secondary);
border-radius: 8px;
}
/* ✅ CORRECT - Empty line before rules */
.dbench-card__header {
font-weight: bold;
}
.dbench-card__content {
padding: 8px;
}/* ✅ CORRECT - Max 3 levels */
.dbench-modal {
.dbench-modal__content {
.dbench-modal__header {
/* This is the maximum depth */
}
}
}
/* ❌ WRONG - Exceeds max depth */
.dbench-modal {
.dbench-level1 {
.dbench-level2 {
.dbench-level3 {
.dbench-level4 { /* Stylelint error */ }
}
}
}
}Stylelint Rule: max-nesting-depth: 3
/* ✅ CORRECT - Modern notation with percentages */
background: rgb(0 0 0 / 12%);
box-shadow: 0 1px 3px rgb(0 0 0 / 24%);
/* ❌ WRONG - Legacy notation with decimals */
background: rgba(0, 0, 0, 0.12); /* Stylelint error */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24); /* Stylelint error */Stylelint Rules:
color-function-notation: "modern"alpha-value-notation: "percentage"
When Obsidian provides a native CSS class for standard UI elements, prefer using it alone rather than layering custom classes on top:
// ✅ CORRECT - Use Obsidian's native dropdown class only
select.className = 'dropdown';
// ❌ AVOID - Custom class layered with native class
select.className = 'dbench-status-select dropdown';Rationale:
- Ensures consistency with Obsidian's look and feel across themes
- Respects user themes and CSS snippets that target native classes
- Avoids CSS specificity conflicts between custom and native styling
- Reduces maintenance burden when Obsidian updates its styling
- Works correctly across different platforms (Windows, macOS, Linux/GTK)
When to Use Custom Classes:
- Container elements that need custom layout (flexbox, grid)
- Elements that don't have an Obsidian equivalent
- Additional styling that doesn't conflict with native behavior
Common Obsidian Native Classes:
| Element | Class | Notes |
|---|---|---|
| Select dropdown | dropdown |
Use alone, don't override appearance |
| Text input | text-input |
Standard text fields |
| Button | mod-cta |
Call-to-action button modifier |
| Setting row | setting-item |
Settings panel rows |
| Clickable icon | clickable-icon |
Icon buttons |
Example - Dropdown in a Custom Container:
// ✅ CORRECT - Custom class on container, native class on element
const container = containerEl.createDiv({ cls: 'dbench-status-picker' });
const select = container.createEl('select', { cls: 'dropdown' });/* Custom layout on container only */
.dbench-status-picker {
display: flex;
gap: 8px;
align-items: center;
}
/* Don't style the dropdown itself - let Obsidian handle it */This section documents critical requirements from Obsidian's Plugin Guidelines that must be followed for plugin approval.
Rule: Never use the global app object. Always use this.app from your plugin instance.
// ❌ WRONG - Global app instance
const file = app.vault.getAbstractFileByPath(path);
// ✅ CORRECT - Plugin instance reference
export default class DraftBenchPlugin extends Plugin {
async onload() {
const file = this.app.vault.getAbstractFileByPath(path);
}
}Why: The global app object (window.app) is for debugging only and may be removed in future versions.
// ❌ WRONG - Excessive logging
console.log('Plugin loaded');
console.log('Processing file:', file.name);
console.log('Done');
// ✅ CORRECT - Only log errors or use structured logging
if (error) {
console.error('Failed to process file:', error);
}
// ✅ BETTER - Use a structured logging system once one exists in the project
const logger = getLogger('SceneWriter');
logger.error('Failed to process scene', error);Rule: Developer console should only show error messages by default. Avoid debug/info logging in production. ESLint config allows console.warn, console.error, and console.debug only.
CRITICAL: Building DOM from user input using these methods creates XSS vulnerabilities.
// ❌ WRONG - Security vulnerability!
function showTitle(title: string) {
let container = document.querySelector('.dbench-container');
container.innerHTML = `<div><b>Scene: </b>${title}</div>`;
// If title = "<script>alert('XSS')</script>", this executes!
}
// ✅ CORRECT - Use DOM API or Obsidian helpers
function showTitle(title: string) {
let container = document.querySelector('.dbench-container');
let div = container.createDiv();
div.createEl('b', { text: 'Scene: ' });
div.appendText(title); // Safe - text is escaped
}
// ✅ BETTER - Obsidian createEl helper
containerEl.createDiv({ cls: 'dbench-container' }, (div) => {
div.createEl('b', { text: 'Scene: ' });
div.appendText(title);
});To cleanup: Use el.empty() instead of setting innerHTML = ''.
// ❌ WRONG - Direct access to activeLeaf
const leaf = this.app.workspace.activeLeaf;
// ✅ CORRECT - Use getActiveViewOfType()
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
// view is guaranteed to be MarkdownView or null
}
// ✅ CORRECT - For editor access
const editor = this.app.workspace.activeEditor?.editor;
if (editor) {
// Safe to use editor
}// ❌ WRONG - Creates memory leaks
export default class DraftBenchPlugin extends Plugin {
private view: ManuscriptView;
onload() {
this.registerView(MANUSCRIPT_VIEW_TYPE, () => this.view = new ManuscriptView());
}
}
// ✅ CORRECT - Let Obsidian manage the reference
export default class DraftBenchPlugin extends Plugin {
onload() {
this.registerView(MANUSCRIPT_VIEW_TYPE, () => new ManuscriptView());
}
// Access view when needed
getManuscriptView(): ManuscriptView | null {
const leaves = this.app.workspace.getLeavesOfType(MANUSCRIPT_VIEW_TYPE);
if (leaves.length > 0 && leaves[0].view instanceof ManuscriptView) {
return leaves[0].view;
}
return null;
}
}// ❌ WRONG - Loses cursor position, selection, folded content
const file = this.app.workspace.getActiveFile();
const content = await this.app.vault.read(file);
const newContent = content.replace('old', 'new');
await this.app.vault.modify(file, newContent);
// ✅ CORRECT - Preserves editor state
const editor = this.app.workspace.activeEditor?.editor;
if (editor) {
const content = editor.getValue();
const newContent = content.replace('old', 'new');
editor.setValue(newContent);
}// ❌ WRONG - Can conflict with other plugins
const content = await this.app.vault.read(file);
const newContent = content.replace('old', 'new');
await this.app.vault.modify(file, newContent);
// ✅ CORRECT - Atomic, conflict-free
await this.app.vault.process(file, (content) => {
return content.replace('old', 'new');
});Why: process() is atomic and prevents conflicts when multiple plugins edit the same file.
This is a hard rule for Draft Bench — every plugin-managed frontmatter write goes through processFrontMatter. We never hand-parse YAML.
// ❌ WRONG - Manual YAML parsing
const content = await this.app.vault.read(file);
const match = content.match(/^---\n([\s\S]*?)\n---/);
const yaml = parseYAML(match[1]);
yaml['dbench-status'] = 'draft';
const newContent = content.replace(match[0], `---\n${stringifyYAML(yaml)}\n---`);
await this.app.vault.modify(file, newContent);
// ✅ CORRECT - Atomic, consistent YAML formatting
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter['dbench-status'] = 'draft';
});Benefits:
- Atomic (no conflicts)
- Consistent YAML layout
- Automatic error handling
- Preserves user-authored properties Draft Bench doesn't manage
// ❌ AVOID - Slower, no safety guarantees
const content = await this.app.vault.adapter.read(path);
await this.app.vault.adapter.write(path, content);
// ✅ PREFER - Cached, safe from race conditions
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
const content = await this.app.vault.read(file);
await this.app.vault.modify(file, content);
}Benefits of Vault API:
- Performance: Caching layer speeds up reads
- Safety: Serial operations prevent race conditions
// ❌ WRONG - O(n) performance, slow on large vaults
const file = this.app.vault.getFiles().find(f => f.path === filePath);
// ✅ CORRECT - O(1) lookup
const file = this.app.vault.getFileByPath(filePath);
const folder = this.app.vault.getFolderByPath(folderPath);
// ✅ CORRECT - When you don't know if it's a file or folder
const abstractFile = this.app.vault.getAbstractFileByPath(path);
if (abstractFile instanceof TFile) {
// It's a file
} else if (abstractFile instanceof TFolder) {
// It's a folder
}import { normalizePath } from 'obsidian';
// ❌ WRONG - Platform-specific issues, unsafe characters
const path = userInput; // Could be "//my-folder\file"
const file = this.app.vault.getAbstractFileByPath(path);
// ✅ CORRECT - Clean, safe, cross-platform
const path = normalizePath(userInput); // Returns "my-folder/file"
const file = this.app.vault.getAbstractFileByPath(path);What normalizePath() does:
- Cleans forward/backward slashes
- Removes leading/trailing slashes
- Replaces non-breaking spaces with regular spaces
- Normalizes Unicode characters
// ✅ CORRECT - Use register methods for automatic cleanup
export default class DraftBenchPlugin extends Plugin {
onload() {
// Auto-cleaned when plugin unloads
this.registerEvent(
this.app.vault.on('create', this.onCreate)
);
this.addCommand({
id: 'create-project',
name: 'Create new project',
callback: () => { }
});
}
onCreate = (file: TAbstractFile) => {
// Event handler
}
}
// ❌ WRONG - Manual cleanup required
export default class DraftBenchPlugin extends Plugin {
private eventRef: EventRef;
onload() {
this.eventRef = this.app.vault.on('create', this.onCreate);
}
onunload() {
this.app.vault.offref(this.eventRef); // Must remember to clean up
}
}Exception: Don't clean up resources that are automatically garbage-collected (like DOM event listeners on elements that will be removed).
// ❌ WRONG - Leaves won't restore to original position on update
onunload() {
this.app.workspace.detachLeavesOfType(MANUSCRIPT_VIEW_TYPE);
}
// ✅ CORRECT - Let Obsidian handle leaf lifecycle
onunload() {
// Don't detach leaves
}Why: When plugin updates, leaves are automatically reinitialized at their original position.
// ❌ WRONG - Can conflict with user settings or other plugins
this.addCommand({
id: 'create-project',
name: 'Create new project',
hotkeys: [{ modifiers: ['Mod'], key: 'k' }], // Don't do this
callback: () => { }
});
// ✅ CORRECT - Let users assign their own hotkeys
this.addCommand({
id: 'create-project',
name: 'Create new project',
callback: () => { }
});Why:
- Different hotkeys available on different OS
- May conflict with user's existing configuration
- May conflict with other plugins
// ✅ Use callback for unconditional commands
this.addCommand({
id: 'open-control-center',
name: 'Open Control Center',
callback: () => {
// Always executes
}
});
// ✅ Use checkCallback for conditional commands
this.addCommand({
id: 'compile-current-project',
name: 'Compile current project',
checkCallback: (checking: boolean) => {
const project = this.getCurrentProject();
if (checking) {
return project !== null; // Return whether command should be enabled
}
if (project) {
// Execute command
}
}
});
// ✅ Use editorCallback when you need the editor
this.addCommand({
id: 'insert-scene-break',
name: 'Insert scene break',
editorCallback: (editor: Editor, view: MarkdownView) => {
editor.replaceSelection('\n* * *\n');
}
});
// ✅ Use editorCheckCallback for conditional editor commands
this.addCommand({
id: 'wrap-selection-in-scene',
name: 'Wrap selection in new scene',
editorCheckCallback: (checking: boolean, editor: Editor, view: MarkdownView) => {
const hasSelection = editor.somethingSelected();
if (checking) {
return hasSelection;
}
if (hasSelection) {
// Process selection
}
}
});// ❌ WRONG - Impossible to theme, override with CSS
const el = containerEl.createDiv();
el.style.color = 'white';
el.style.backgroundColor = 'red';
// ✅ CORRECT - Use CSS classes
const el = containerEl.createDiv({ cls: 'dbench-warning' });In your CSS file:
.dbench-warning {
color: var(--text-normal);
background-color: var(--background-modifier-error);
}Why:
- Allows users to customize with themes/snippets
- Respects user's color preferences
- Consistent with Obsidian styling
Use Obsidian CSS variables:
--text-normal,--text-muted,--text-faint--background-primary,--background-secondary--background-modifier-border,--background-modifier-error--interactive-accent,--interactive-accent-hover
See the obsidian-plugin-ui skill (.claude/skills/obsidian-plugin-ui/SKILL.md) for the full CSS variable reference.
ESLint Rule: obsidianmd/no-static-styles-assignment: "error" catches direct .style.* assignments.
Already covered in § 2.4. Variable Declarations.
// ❌ WRONG - Harder to read, error-prone
function fetchData(): Promise<string | null> {
return requestUrl('https://example.com')
.then(res => res.text)
.catch(e => {
console.error(e);
return null;
});
}
// ✅ CORRECT - Clearer, easier to debug
async function fetchData(): Promise<string | null> {
try {
const res = await requestUrl('https://example.com');
const text = await res.text;
return text;
} catch (e) {
console.error(e);
return null;
}
}Reference: Obsidian Plugin Guidelines
When the settings tab grows beyond a few sections, decompose display() into private render methods for maintainability:
// ✅ CORRECT - Decomposed settings tab structure
export class DraftBenchSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
// Decomposed into section methods — no monolithic display()
this.renderProjectsSection(containerEl);
this.renderTemplatesSection(containerEl);
this.renderStatusSection(containerEl);
this.renderCompileSection(containerEl);
this.renderAdvancedSection(containerEl);
}
private renderProjectsSection(containerEl: HTMLElement): void {
// Use .setHeading() for section headers, sentence case
new Setting(containerEl)
.setName('Projects')
.setHeading();
// Sentence case for all UI text
new Setting(containerEl)
.setName('Default project folder')
.setDesc('Where new projects are created')
.addText(text => text
.setValue(this.plugin.settings.defaultProjectFolder)
.onChange(async (value) => {
this.plugin.settings.defaultProjectFolder = value;
await this.plugin.saveSettings();
}));
}
}The project uses eslint-plugin-obsidianmd to enforce Obsidian-specific best practices automatically. These rules catch common anti-patterns that wouldn't be flagged by standard TypeScript linting.
The following rules are active in eslint.config.mjs:
| Rule | What it catches |
|---|---|
obsidianmd/no-forbidden-elements |
Attaching forbidden elements to the DOM |
obsidianmd/no-static-styles-assignment |
Setting .style.* directly on DOM elements: use CSS classes instead |
obsidianmd/vault/iterate |
Iterating all files to find one by path: use getAbstractFileByPath() |
obsidianmd/detach-leaves |
Detaching leaves in onunload (Obsidian handles this) |
obsidianmd/hardcoded-config-path |
Hard-coded .obsidian/ paths |
obsidianmd/no-plugin-as-component |
Passing the plugin instance as a Component to MarkdownRenderer.render: causes memory leaks |
obsidianmd/no-sample-code |
Unmodified sample-plugin boilerplate remaining in the codebase |
obsidianmd/no-tfile-tfolder-cast |
Casting to TFile/TFolder: use instanceof checks instead |
obsidianmd/no-view-references-in-plugin |
Storing view references on the plugin instance: causes memory leaks |
obsidianmd/platform |
Using the navigator API for OS detection: use Obsidian's Platform helper |
obsidianmd/prefer-file-manager-trash-file (warn) |
Using Vault.trash()/Vault.delete(): prefer FileManager.trashFile() so the user's "move to trash" preference is respected |
obsidianmd/regex-lookbehind |
Regex lookbehinds: not supported on some iOS versions |
obsidianmd/sample-names |
Sample plugin class names (MyPlugin, MyPluginSettingTab) left unchanged |
| Rule | What it catches |
|---|---|
obsidianmd/commands/no-command-in-command-id |
The word "command" in a command ID |
obsidianmd/commands/no-command-in-command-name |
The word "command" in a command name |
obsidianmd/commands/no-default-hotkeys |
Providing default hotkeys (hotkeys should be user-configured) |
obsidianmd/commands/no-plugin-id-in-command-id |
The plugin ID inside a command ID |
obsidianmd/commands/no-plugin-name-in-command-name |
The plugin name inside a command name |
| Rule | What it catches |
|---|---|
obsidianmd/settings-tab/no-manual-html-headings |
<h1>/<h2>/etc. for settings headings: use new Setting().setHeading() (see § 5.4) |
obsidianmd/settings-tab/no-problematic-settings-headings |
Anti-patterns in settings headings (Title Case, plugin name in heading, etc.) |
| Rule | What it catches |
|---|---|
obsidianmd/ui/sentence-case |
UI strings not in sentence case. See § 5.1 for the general requirement and the false-positive guidance immediately below for project-specific exceptions |
The obsidianmd/ui/sentence-case rule produces false positives in any non-trivial codebase. When reviewing warnings:
Always skip (false positives):
- HTML element names (
th,td,li,div, etc.) - ARIA attributes (
aria-label,aria-describedby) - Code identifiers and CSS class names
- Separator characters (
,,|) - Example/placeholder text (e.g.,
e.g., Chapter 1) - Navigation symbols (
←,->)
Usually skip (intentional Title Case):
- Entity type names when used as labels:
Create new Scene,Add to Project as… - Button labels referencing UI elements:
Click Project to select - Proper nouns not in the brands list
Review and potentially fix:
- Generic title-cased phrases that should be sentence case:
Compile Preset->Compile preset - Product references: use
Obsidian Bases(the feature is a proper noun) butmanuscript(generic noun) notManuscript
To add recurring false positives permanently: Edit eslint.config.mjs and add terms to the brands or acronyms arrays in the sentence-case rule config. These arrays REPLACE defaults, so essential defaults are already included.
Other rules: the structural rules (everything above ui/sentence-case in the tables) catch real anti-patterns. The fix is almost always to refactor rather than disable the rule.
This section documents Obsidian's official UI text guidelines from the Plugin guidelines.
CRITICAL: All UI text must use sentence case, NOT Title Case.
Sentence case means only the first word and proper nouns are capitalized.
// ✅ CORRECT - Sentence case
.setName('Default project folder')
.setName('Create new scene')
.setName('Compile preset')
.setName('Show word counts')
// ❌ WRONG - Title Case
.setName('Default Project Folder')
.setName('Create New Scene')
.setName('Compile Preset')
.setName('Show Word Counts')Applies to:
- Setting names (
.setName()) - Button text
- Command names
- Modal titles
- Section headings
- Form labels
- Any user-facing text in the UI
The following proper nouns should remain capitalized even in sentence case contexts:
| Category | Examples |
|---|---|
| Plugin name | Draft Bench |
| Feature names | Control Center, Manuscript, Compile, Book Builder |
| Third-party plugins / products | Obsidian Bases, Templater, Dataview, Longform, Excalidraw |
| External tools | Pandoc |
| Acronyms | UUID, ID, PNG, SVG, CSV, PDF, ODT, MD, XML, JSON, YAML, BRAT |
// ✅ CORRECT - Proper nouns stay capitalized
.setTitle('Draft Bench: Open Control Center') // Plugin name capitalized
.setName('Compile to PDF') // Acronym
.setName('Export with Pandoc') // External tool
.setName('Open in Bases view') // Obsidian Bases is a feature/product name
// ❌ WRONG - Don't lowercase proper nouns
.setTitle('drafting table: Open control center') // Plugin name should be capitalized
.setName('Compile to pdf') // Acronym should be all caps
.setName('Export with pandoc') // External tool name should be capitalizedRules for settings headings:
-
Only use headings if you have more than one section
- Don't add a top-level heading like "General", "Settings", or your plugin name
- Keep general settings at the top without a heading
-
Avoid "settings" in heading text
- Prefer "Advanced" over "Advanced settings"
- Prefer "Templates" over "Settings for templates"
- Everything under the settings tab is already settings—don't be redundant
-
Use sentence case for headings
- Prefer "Compile presets" over "Compile Presets"
- Prefer "Templates" over "Templates" (already sentence case)
// ✅ CORRECT - Settings structure
export class DraftBenchSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
// General settings at top - NO heading
new Setting(containerEl)
.setName('Default project folder')
.setDesc('Where new projects are created');
// Section heading - sentence case, no "settings"
new Setting(containerEl)
.setName('Advanced')
.setHeading();
new Setting(containerEl)
.setName('Enable debug logging')
.setDesc('Show additional logging information');
}
}
// ❌ WRONG - Common mistakes
export class DraftBenchSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
// ❌ Don't add top-level heading
containerEl.createEl('h2', { text: 'Draft Bench Settings' });
// ❌ Title Case
containerEl.createEl('h3', { text: 'Advanced Settings' });
// ❌ Redundant "settings" + Title Case
new Setting(containerEl)
.setName('Default Project Folder'); // Should be sentence case
}
}Use .setHeading() instead of HTML heading elements for consistent styling:
// ✅ CORRECT
new Setting(containerEl)
.setName('Advanced')
.setHeading();
// ❌ WRONG - Inconsistent styling
containerEl.createEl('h3', { text: 'Advanced' });The obsidianmd/settings-tab/no-manual-html-headings ESLint rule catches the wrong form.
Reference: Obsidian Plugin Guidelines - UI text
# Check for errors
npm run lint
# Auto-fix errors
npm run lint:fix# Check CSS
npm run lint:css
# Auto-fix CSS
npm run lint:css:fix
# Format CSS with Prettier
npm run format:css# Production build (TypeScript typecheck + esbuild bundle)
npm run build
# Watch mode for development
npm run devThe build script invokes tsc and esbuild via direct node ./node_modules/<pkg>/bin/<entry> paths because the project lives on a Windows drive accessed through WSL2's DrvFS, which doesn't support Linux-style symlinks for node_modules/.bin. Don't change scripts to bare tool names — npm install --no-bin-links (the required install mode) leaves .bin/ empty.
Three build artifacts live at the repo root. Treatment differs by file:
styles.css— concatenated fromstyles/*.cssbybuild-css.js(npm run build:css). Committed to git. A diff signals a real content change; rebuild before commit.main.js— bundled frommain.ts+src/by esbuild (npm run build). Gitignored. Produced fresh by the release workflow (.github/workflows/release.yml) for every tag push; attached to the GitHub release with a provenance attestation.main.js.map— source map, emitted alongsidemain.js. Gitignored. Local-only build artifact; not part of the release asset set.
Rules:
- Never hand-edit these files. Edit the sources (
styles/*.css,main.ts,src/**/*.ts) and rebuild. - Builds are deterministic.
build-css.jsemits no timestamps; esbuild is reproducible. The locally-builtmain.jsshould byte-match the CI-built artifact for the same source commit + Node version (pinned in.nvmrc). styles.csskeeps its source -> bundle commit discipline. When you changestyles/*.css, runnpm run build:cssand commit the resultingstyles.cssdiff alongside the source change. The release workflow re-runs the build in CI, but the committed bundle is what tools that readmaindirectly (theme-style auditors, BRAT-prerelease testers reading current source) see between tagged releases.main.jsis the release artifact, not a source-tree artifact. Local builds during dev producemain.jsfor vault deployment via./deploy.sh. Tagged releases produce the canonicalmain.jsvia CI, with attestation. Users install via BRAT or the community plugin directory; both resolve to release assets, not branch checkouts.- Release-time provenance. The CI workflow runs
actions/attest-build-provenance@v2againstmain.js,manifest.json, andstyles.css. End users can verify withgh attestation verify <file> --repo banisterious/obsidian-draft-bench. See CLAUDE.md § 4 "Release process" for the tag-push -> CI -> draft-release flow. styles.cssis out of scope for Stylelint and Prettier.npm run lint:cssandnpm run format:csstargetstyles/**/*.css(the sources)..stylelintrc.jsonexplicitly ignoresstyles.css.
// ❌ Error: 'value' is never reassigned. Use 'const' instead
let value = 'test';
console.log(value);
// ✅ Fix: Use const
const value = 'test';
console.log(value);// ❌ Error: 'result' is assigned a value but never used
const result = calculateValue();
// ✅ Fix 1: Remove if truly unused
// (delete the line)
// ✅ Fix 2: Prefix with _ if intentionally unused
const _result = calculateValue(); // Explicitly ignored
// ✅ Fix 3: Use the variable
const result = calculateValue();
console.log(result);// ❌ Error: Unexpected any. Specify a different type
const data: any = getValue();
// ✅ Fix 1: Use a proper type
interface SceneFrontmatter {
'dbench-type': 'scene';
'dbench-project': string;
'dbench-order': number;
}
const data: SceneFrontmatter = getValue();
// ✅ Fix 2: Use unknown for truly dynamic data
const data: unknown = getValue();
if (typeof data === 'object' && data !== null) {
// Type guard to safely use data
}These are runtime-injected by Obsidian onto the global scope. They're declared as ESLint globals in eslint.config.mjs. If you see no-undef errors for these, check that the globals block is intact:
globals: {
...globals.browser,
...globals.node,
// Obsidian adds these to the global scope at runtime
createDiv: 'readonly',
createEl: 'readonly',
createSpan: 'readonly',
createFragment: 'readonly',
},// ❌ Error: Spaces are hard to count. Use {2}
const pattern = /^ - (.+)$/gm;
// ✅ Fix: Use quantifier
const pattern = /^ {2}- (.+)$/gm;/* ❌ Error: Expected ".xyz-modal" to match pattern */
.xyz-modal { }
/* ✅ Fix: Use 'dbench-' or 'draft-bench-' prefix */
.dbench-modal { }
.draft-bench-modal { }
/* ❌ Error: Expected ".modalContainer" to match pattern */
.modalContainer { }
/* ✅ Fix: Use kebab-case with prefix */
.dbench-modal-container { }
.draft-bench-modal-container { }/* ❌ Error: Expected "--modal-width" to match pattern */
:root {
--modal-width: 800px;
}
/* ✅ Fix: Add 'dbench-' prefix */
:root {
--dbench-modal-width: 800px;
}/* ❌ Error: Expected modern color-function notation */
background: rgba(0, 0, 0, 0.12);
/* ✅ Fix: Use modern notation with percentage */
background: rgb(0 0 0 / 12%);/* ❌ Error: Expected "0 24px 24px 24px" to be "0 24px 24px" */
padding: 0 24px 24px 24px;
/* ✅ Fix: Remove redundant value */
padding: 0 24px 24px;Status (2026-04-16): Draft Bench is pre-implementation. There are no shared utilities yet. This section is a placeholder for the pattern to follow once Phase 1+ work begins.
When the same inline pattern appears in three or more places (formatting, normalization, frontmatter shaping, etc.), extract it to a single source under src/utils/ (or a more specific subfolder) and update callers. Document the new utility here with:
- A short description
- The import path
- A
// ✅ CORRECT: use shared utilityexample showing the recommended call - A
// ❌ WRONG: inline duplicationexample showing the anti-pattern it replaces
When services depend on plugin settings, folder filters, or runtime context, expose them via plugin methods (createXyzService() for new instances, getXyzService() for singletons) rather than letting callers construct + configure manually. This avoids duplicated setup that may miss required steps.
| Service | Plugin method | Pattern | Notes |
|---|---|---|---|
| (none yet) |
Add rows here as services are introduced.
- eslint.config.mjs - ESLint configuration
- .stylelintrc.json - Stylelint configuration
- .prettierrc.json - Prettier configuration
- obsidian-plugin-ui skill - Obsidian UI patterns, theming, accessibility
- specification.md - Plugin specification (data model, UI, phased plan)
- Obsidian API Documentation
- Obsidian Plugin Guidelines