- 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 Charted Roots 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:
person-note-writer.ts,family-graph.ts - Directories: Use kebab-case:
src/core/,src/ui/
| Type | Convention | Example |
|---|---|---|
| Interfaces | PascalCase | PersonData, CanvasRootsSettings |
| Classes | PascalCase | ControlCenterModal, PersonPicker |
| Functions | camelCase | createPersonNote(), generateCrId() |
| Variables | camelCase | frontmatter, spouseValues |
| Constants | SCREAMING_SNAKE_CASE | DEFAULT_SETTINGS, MAX_DEPTH |
| Type Parameters | Single uppercase letter or PascalCase | T, TNode, PersonType |
IMPORTANT: Use camelCase for all settings properties, NOT Sentence Case.
// ✅ CORRECT
export interface CanvasRootsSettings {
defaultNodeWidth: number;
horizontalSpacing: number;
autoGenerateCrId: boolean;
peopleFolder: string;
}
// ❌ WRONG - Do NOT use Sentence Case or spaces
export interface CanvasRootsSettings {
"Default Node Width": number; // Never do this!
horizontal_spacing: number; // 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 node width') // Sentence case (lowercase after first word)
.setDesc('Width of person nodes in pixels') // Sentence case
.addText(text => text
.setValue(String(this.plugin.settings.defaultNodeWidth)) // camelCase property
// ❌ WRONG - Title Case
new Setting(containerEl)
.setName('Default Node Width') // Title Case - Don't do this!
.setDesc('Width of person nodes in pixels')Reference: Obsidian Style Guide - Sentence case
- Indentation: Use tabs (configured in
.editorconfig) - 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 createPersonNote(
app: App,
person: PersonData
): Promise<TFile> {
// ...
}
// ✅ Document complex functions with JSDoc
/**
* Create a person note with YAML frontmatter
*
* @param app - Obsidian app instance
* @param person - Person data
* @returns The created TFile
*/
export async function createPersonNote(/* ... */) {
// ...
}// ✅ Group imports: external → Obsidian → internal
import { App, TFile, normalizePath } from 'obsidian';
import { generateCrId } from './uuid';
import { getLogger } from './logging';// ❌ AVOID
const frontmatter: Record<string, any> = {};
// ✅ PREFER - Be specific
interface Frontmatter {
cr_id: string;
name?: string;
father?: string;
mother?: string;
born?: string;
died?: string;
}
const frontmatter: Frontmatter = { cr_id: crId };
// ✅ ACCEPTABLE - For truly dynamic data, use unknown
const rawData: unknown = JSON.parse(content);ESLint Rule: @typescript-eslint/no-explicit-any: "warn"
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 YAML serialization// ✅ Always annotate function parameters and return types
function calculateAge(birthDate: string): number | null {
// ...
}
// ✅ Annotate complex object literals
const config: LayoutConfig = {
width: 200,
height: 100,
spacing: { horizontal: 50, vertical: 100 }
};// ✅ CORRECT - Use const for values that don't change
const crId = generateCrId();
const files = app.vault.getMarkdownFiles();
// ✅ CORRECT - Use let only when reassigning
let spouseValues: string[] = [];
spouseValues = extractSpouseValues(frontmatter);
// ❌ 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 processNode(
node: PersonNode,
_index: number, // Unused parameter
_array: PersonNode[] // Unused parameter
): void {
console.log(node.name);
}
// ✅ CORRECT - Use destructuring to omit unused values
const { name, birthDate } = person; // Don't destructure unneeded fields
// ❌ WRONG - Don't declare unused variables
const healthBar = createHealthBar(); // 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 = person.name;
// console.log(oldValue);
// ✅ CORRECT - Remove or use version control for historyAll CSS classes MUST follow BEM (Block__Element--Modifier) with project prefix:
Pattern: (cr|crc|canvas-roots)-[block](__[element])?(--[modifier])?
/* ✅ CORRECT - Block */
.cr-modal-container { }
.crc-card { }
/* ✅ CORRECT - Block + Element */
.cr-card__header { }
.crc-card__title { }
.cr-nav-item__icon { }
/* ✅ CORRECT - Block + Modifier */
.cr-btn--primary { }
.crc-nav-item--active { }
/* ✅ CORRECT - Block + Element + Modifier */
.cr-nav-item__icon--disabled { }
/* ❌ WRONG - Missing prefix */
.modal-container { } /* Stylelint error */
/* ❌ WRONG - Camel case */
.crModalContainer { } /* Stylelint error */
/* ❌ WRONG - Sentence case or spaces */
.cr-modal container { } /* Stylelint error */Allowed Prefixes:
cr-(Charted Roots) - Short prefixcrc-(Charted Roots Component) - Alternate short prefixcanvas-roots-- Long form (use sparingly)
| Component | Class Name |
|---|---|
| Modal container | .cr-modal-container |
| Card header | .cr-card__header |
| Primary button | .cr-btn--primary |
| Active nav item | .cr-nav-item--active |
| Person picker | .cr-person-picker |
Custom properties (CSS variables) MUST use kebab-case with prefix:
Pattern: (md|cr)-[name]
/* ✅ CORRECT - Material Design variables */
--md-primary-color: #3498db;
--md-surface-elevation: 2;
/* ✅ CORRECT - Charted Roots variables */
--cr-node-width: 200px;
--cr-spacing-horizontal: 50px;
/* ❌ WRONG - Missing prefix */
--modal-width: 800px; /* Stylelint error */
/* ❌ WRONG - Camel case */
--crModalWidth: 800px; /* Stylelint error */
/* ❌ WRONG - Using Obsidian variables directly */
/* These are OK to USE but not to DEFINE in your CSS */
--background-primary: #fff; /* Only use existing Obsidian vars */Exception: You can USE Obsidian's built-in CSS variables:
/* ✅ CORRECT - Using Obsidian variables */
.cr-modal {
background: var(--background-primary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
}Overrides for specific files:
styles/variables.css- Custom property pattern not enforcedstyles/theme.css- Selector class pattern not enforced
/* ✅ CORRECT - Lowercase everything */
.cr-button {
color: #3498db;
font-family: sans-serif;
}
/* ✅ CORRECT - Double quotes for strings */
.cr-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 */
.cr-card {
padding: 16px;
background: var(--background-secondary);
border-radius: 8px;
}
/* ✅ CORRECT - Empty line before rules */
.cr-card__header {
font-weight: bold;
}
.cr-card__content {
padding: 8px;
}/* ✅ CORRECT - Max 3 levels */
.cr-modal {
.cr-modal__content {
.cr-modal__header {
/* This is the maximum depth */
}
}
}
/* ❌ WRONG - Exceeds max depth */
.cr-modal {
.cr-level1 {
.cr-level2 {
.cr-level3 {
.cr-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 = 'cr-source-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: 'cr-source-picker' });
const select = container.createEl('select', { cls: 'dropdown' });/* Custom layout on container only */
.cr-source-picker {
display: flex;
gap: 8px;
align-items: center;
}
/* Don't style the dropdown itself - let Obsidian handle it */For per-component mobile styling, use the cr-mobile / cr-desktop / cr-phone / cr-tablet classes that MobileClassManager attaches to each registered view's container element. Media queries are backup only — for cases that genuinely correlate with viewport width or print, not with Obsidian's platform detection.
Rationale:
The v0.22.20 #528 Map View fix established empirically that @media (max-width: 768px) doesn't fire reliably on Obsidian Mobile. The viewport in Obsidian's mobile shell isn't always what max-width: 768px would intuitively select against, and the mismatch produced silently-broken layouts. Class-based selectors driven by Platform.is* flags resolve correctly.
Available classes (attached to each registered view's container at construction):
| Class | Applied when |
|---|---|
cr-mobile |
Platform.isMobile === true |
cr-desktop |
Platform.isDesktop === true |
cr-phone |
Platform.isPhone === true |
cr-tablet |
Platform.isTablet === true |
The four classes aren't mutually exclusive — some hybrid platforms report both cr-mobile and cr-desktop (the same quirk that motivated shouldUseSubmenu()'s dual check). Stylesheets that need unambiguous desktop scope can select on .cr-desktop:not(.cr-mobile).
Migration pattern (per-component, Phase 4b):
/* ❌ AVOID - viewport media query (unreliable on Obsidian Mobile) */
@media (max-width: 768px) {
.cr-foo {
padding: 8px;
}
}
/* ✅ PREFER - class-based scope */
.cr-mobile .cr-foo {
padding: 8px;
}When media queries are still appropriate:
- Print stylesheets (
@media print) - Theme overrides via
prefers-color-scheme - Genuine viewport-width concerns where the layout should reflow when a desktop user narrows a pane — those don't correlate with
Platform.is*and remain media-query-driven. Document the rationale inline.
These cases live in styles/responsive.css (repurposed during Phase 4a from "everything responsive" to "the few things that genuinely need media queries").
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 MyPlugin 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 structured logging system
const logger = getLogger('MyComponent');
logger.error('Failed to process file', error);Rule: Developer console should only show error messages by default. Avoid debug/info logging in production.
CRITICAL: Building DOM from user input using these methods creates XSS vulnerabilities.
// ❌ WRONG - Security vulnerability!
function showName(name: string) {
let container = document.querySelector('.my-container');
container.innerHTML = `<div><b>Your name is: </b>${name}</div>`;
// If name = "<script>alert('XSS')</script>", this executes!
}
// ✅ CORRECT - Use DOM API or Obsidian helpers
function showName(name: string) {
let container = document.querySelector('.my-container');
let div = container.createDiv();
div.createEl('b', { text: 'Your name is: ' });
div.appendText(name); // Safe - text is escaped
}
// ✅ BETTER - Obsidian createEl helper
containerEl.createDiv({ cls: 'my-container' }, (div) => {
div.createEl('b', { text: 'Your name is: ' });
div.appendText(name);
});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 MyPlugin extends Plugin {
private view: MyCustomView;
onload() {
this.registerView(MY_VIEW_TYPE, () => this.view = new MyCustomView());
}
}
// ✅ CORRECT - Let Obsidian manage the reference
export default class MyPlugin extends Plugin {
onload() {
this.registerView(MY_VIEW_TYPE, () => new MyCustomView());
}
// Access view when needed
getMyView(): MyCustomView | null {
const leaves = this.app.workspace.getLeavesOfType(MY_VIEW_TYPE);
if (leaves.length > 0 && leaves[0].view instanceof MyCustomView) {
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.
// ❌ 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.newField = 'value';
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.newField = 'value';
});Benefits:
- Atomic (no conflicts)
- Consistent YAML layout
- Automatic error handling
// ❌ WRONG - Reads stale cache; the metadata cache hasn't caught up yet
await this.app.fileManager.processFrontMatter(file, (fm) => { fm.x = 'y'; });
const cache = this.app.metadataCache.getFileCache(file);
processBasedOn(cache?.frontmatter?.x); // may be undefined or pre-write
// ✅ CORRECT - Bridge the asynchronous catch-up window
import { waitForCacheRefresh } from '../utils/cache-utils';
await this.app.fileManager.processFrontMatter(file, (fm) => { fm.x = 'y'; });
await waitForCacheRefresh(this.app, file);
const cache = this.app.metadataCache.getFileCache(file);
processBasedOn(cache?.frontmatter?.x); // reflects the writeWhy: processFrontMatter (and vault.create / vault.modify) writes the file synchronously, but Obsidian's metadata cache updates asynchronously via the file watcher event. Reading getFileCache(file) immediately after the write returns can hand back stale data. waitForCacheRefresh listens for the next metadataCache.changed event for the target file with a 500ms timeout fallback. See Cache Utilities for usage details.
Don't use a fixed setTimeout delay: brittle on slow systems, wasted UX on fast ones. The event-driven wait resolves in tens of milliseconds typically.
// ❌ 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 MyPlugin extends Plugin {
onload() {
// Auto-cleaned when plugin unloads
this.registerEvent(
this.app.vault.on('create', this.onCreate)
);
this.addCommand({
id: 'my-command',
name: 'My command',
callback: () => { }
});
}
onCreate = (file: TAbstractFile) => {
// Event handler
}
}
// ❌ WRONG - Manual cleanup required
export default class MyPlugin 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(MY_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: 'my-command',
name: 'My command',
hotkeys: [{ modifiers: ['Mod'], key: 'k' }], // Don't do this
callback: () => { }
});
// ✅ CORRECT - Let users assign their own hotkeys
this.addCommand({
id: 'my-command',
name: 'My command',
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: 'always-runs',
name: 'Always runs',
callback: () => {
// Always executes
}
});
// ✅ Use checkCallback for conditional commands
this.addCommand({
id: 'sometimes-runs',
name: 'Sometimes runs',
checkCallback: (checking: boolean) => {
const canRun = someCondition();
if (checking) {
return canRun; // Return whether command should be enabled
}
if (canRun) {
// Execute command
}
}
});
// ✅ Use editorCallback when you need the editor
this.addCommand({
id: 'editor-command',
name: 'Editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
editor.replaceSelection('Hello');
}
});
// ✅ Use editorCheckCallback for conditional editor commands
this.addCommand({
id: 'conditional-editor',
name: 'Conditional editor command',
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: 'warning-container' });In your CSS file:
.warning-container {
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 [[CSS variables]] for complete list.
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
The Charted Roots settings tab decomposes display() into multiple private render methods for maintainability:
// ✅ CORRECT - Decomposed settings tab structure
export class CanvasRootsSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
// Decomposed into section methods — no monolithic display()
this.renderFoldersSection(containerEl);
this.renderDataSection(containerEl);
this.renderCanvasSection(containerEl);
this.renderDatesSection(containerEl);
this.renderSexSection(containerEl);
this.renderPlacesSection(containerEl);
this.renderAliasesSection(containerEl);
this.renderAdvancedSection(containerEl);
}
private renderFoldersSection(containerEl: HTMLElement): void {
// Use .setHeading() for section headers, sentence case
new Setting(containerEl)
.setName('Folders')
.setHeading();
// Sentence case for all UI text
new Setting(containerEl)
.setName('People folder')
.setDesc('Location for person notes')
.addText(text => text
.setValue(this.plugin.settings.peopleFolder)
.onChange(async (value) => {
this.plugin.settings.peopleFolder = 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 many false positives in this 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., John Smith) - Navigation symbols (
←,→)
Usually skip (intentional Title Case):
- Entity type names when used as labels:
Create Events base,People folder,Places base - Button labels referencing UI elements:
Click Link to select - Proper nouns not in the brands list
Review and potentially fix:
- Generic title-cased phrases that should be sentence case:
Event Timeline→Event timeline - Product references: use
Obsidian canvas(the feature) notObsidian Canvas(Canvas is not a product name)
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('Template folder location')
.setName('Create new note')
.setName('Root person')
.setName('Default node width')
// ❌ WRONG - Title Case
.setName('Template Folder Location')
.setName('Create New Note')
.setName('Root Person') // This is what you found!
.setName('Default Node Width')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 | Charted Roots |
| Feature names | Family Chart, Control Center |
| Third-party products | Excalidraw, Obsidian Canvas, Obsidian Bases |
| Industry standards | GEDCOM, GEDCOM X |
| Genealogy software | Gramps |
| Numbering systems | Ahnentafel, d'Aboville, Henry, Modified Register |
| Acronyms | UUID, ID, PNG, SVG, CSV, PDF, XML, JSON, CR |
| Format names | Chicago, MLA, Turabian, Evidence Explained |
// ✅ CORRECT - Proper nouns stay capitalized
.setTitle('Charted Roots: Regenerate canvas') // Plugin name capitalized
.setName('Export to Excalidraw') // Third-party product
.setName('GEDCOM import options') // Industry standard acronym
.setName('Assign Ahnentafel numbers') // Genealogical system name
.setName('Open in Family Chart') // Feature name
// ❌ WRONG - Don't lowercase proper nouns
.setTitle('canvas roots: Regenerate canvas') // Plugin name should be capitalized
.setName('Export to excalidraw') // Product name should be capitalized
.setName('Gedcom import options') // Acronym should be all capsRules 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 "Layout configuration" over "Layout Configuration"
- Prefer "Data options" over "Data Options"
// ✅ CORRECT - Settings structure
export class MySettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
// General settings at top - NO heading
new Setting(containerEl)
.setName('Default folder')
.setDesc('Location for new notes');
// Section heading - sentence case, no "settings"
containerEl.createEl('h3', { text: 'Advanced' });
new Setting(containerEl)
.setName('Enable debug mode')
.setDesc('Show additional logging information');
}
}
// ❌ WRONG - Common mistakes
export class MySettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
// ❌ Don't add top-level heading
containerEl.createEl('h2', { text: 'My Plugin Settings' });
// ❌ Title Case
containerEl.createEl('h3', { text: 'Advanced Settings' });
// ❌ Redundant "settings" + Title Case
new Setting(containerEl)
.setName('Default Folder Location'); // 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' });Based on the official guidelines, here are corrections for Control Center UI text:
// ❌ CURRENT (WRONG) - Title Case
const rootLabel = rootGroup.createEl('label', {
cls: 'crc-form-label',
text: 'Root Person' // Title Case - WRONG
});
// ✅ CORRECTED - Sentence case
const rootLabel = rootGroup.createEl('label', {
cls: 'crc-form-label',
text: 'Root person' // Sentence case - CORRECT
});
// ❌ CURRENT (WRONG) - Title Case
const configTitle = configHeader.createEl('h3', {
cls: 'crc-card__title',
text: 'Tree Configuration' // Title Case - WRONG
});
// ✅ CORRECTED - Sentence case
const configTitle = configHeader.createEl('h3', {
cls: 'crc-card__title',
text: 'Tree configuration' // Sentence case - CORRECT
});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# Full build (includes TypeScript check, esbuild, CSS build)
npm run build
# Build with CSS linting disabled (for development)
npm run build:css -- --no-fail-on-lint// ❌ 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);// ⚠️ Warning: Unexpected any. Specify a different type
const data: any = getValue();
// ✅ Fix 1: Use a proper type
interface DataType {
name: string;
value: number;
}
const data: DataType = getValue();
// ✅ Fix 2: Use unknown for truly dynamic data
const data: unknown = getValue();
if (typeof data === 'object') {
// Type guard to safely use data
}// ❌ 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 'cr-' or 'crc-' prefix */
.cr-modal { }
.crc-modal { }
/* ❌ Error: Expected ".modalContainer" to match pattern */
.modalContainer { }
/* ✅ Fix: Use kebab-case with prefix */
.cr-modal-container { }
.crc-modal-container { }/* ❌ Error: Expected "--modal-width" to match pattern */
:root {
--modal-width: 800px;
}
/* ✅ Fix: Add 'cr-' or 'md-' prefix */
:root {
--cr-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;Use these shared utilities instead of writing inline equivalents. They were extracted from ~200 duplicate patterns across the codebase.
import { capitalize, pluralize, splitAndTrim, formatPronouns } from '../utils/format-utils';
// ✅ CORRECT — use shared utilities
const label = capitalize(typeName); // "parent" → "Parent"
const summary = `${count} ${pluralize(count, 'person')}`; // "3 persons"
const tags = splitAndTrim(tagString); // "a, b, , c" → ["a", "b", "c"]
// ❌ WRONG — inline duplication
const label = typeName.charAt(0).toUpperCase() + typeName.slice(1);
const summary = `${count} person${count === 1 ? '' : 's'}`;
const tags = tagString.split(',').map(s => s.trim()).filter(Boolean);import { nodeToReportPerson, normalizeSex } from './report-utils';
// Convert a PersonNode for report output
const person = nodeToReportPerson(node);
// Normalize sex values to canonical form
const sex = normalizeSex(raw); // "M" → "male", "F" → "female", etc.import { DynamicContentService } from '../services/dynamic-content-service';
// Standard error/loading states for code block processors
DynamicContentService.renderBlockError(el, 'Could not find person note');
DynamicContentService.renderBlockLoading(el, 'Loading timeline...');import { waitForCacheRefresh } from '../utils/cache-utils';
// After processFrontMatter / vault.create / vault.modify, wait for the
// metadata cache to reflect the just-written change before reading it back.
await app.fileManager.processFrontMatter(file, (fm) => { fm.x = 'y'; });
await waitForCacheRefresh(app, file);
const fresh = app.metadataCache.getFileCache(file); // reflects the write
// Batch writes — wait for all touched files in parallel
const modified: TFile[] = [];
for (const file of files) {
await app.fileManager.processFrontMatter(file, /* ... */);
modified.push(file);
}
await Promise.all(modified.map(f => waitForCacheRefresh(app, f)));Used by the cache-holding services (FamilyGraphService, PlaceGraphService, OrganizationService, UniverseService) inside their reloadCache(modifiedFiles?) methods. When you build a similar service or write a batch flow, prefer this helper to fixed-delay setTimeout waits — it resolves on the next metadataCache.changed event for the target file (typically tens of milliseconds) with a 500ms timeout fallback.
When plugin access is available, always use the plugin's factory/getter methods instead of constructing services directly. These methods handle all configuration (folder filters, property aliases, value aliases, settings) automatically.
// ✅ CORRECT — use plugin factory/getter methods
const familyGraph = this.plugin.createFamilyGraphService();
const placeGraph = this.plugin.createPlaceGraphService();
const sourceService = this.plugin.getSourceService();
// ❌ WRONG — constructing and configuring manually
const familyGraph = new FamilyGraphService(this.app);
familyGraph.setFolderFilter(new FolderFilterService(this.plugin.settings));
familyGraph.setPropertyAliases(this.plugin.settings.propertyAliases);
// ... duplicated configuration that may miss required setup steps| Service | Plugin method | Pattern | Notes |
|---|---|---|---|
FamilyGraphService |
createFamilyGraphService() |
Factory (new instance each call) | Sets folder filter, property/value aliases, settings, person index |
PlaceGraphService |
createPlaceGraphService() |
Factory (new instance each call) | Sets folder filter, settings, value aliases |
SourceService |
getSourceService() |
Lazy singleton | Created on first call, reused thereafter |
EventService |
getEventService() |
Singleton (created in onload) | Returns null if not yet initialized |
For service-layer code without plugin access (exporters, report generators), accept an optional service parameter with a fallback:
constructor(app: App, settings: Settings, sourceService?: SourceService) {
this.sourceService = sourceService ?? new SourceService(app, settings);
}- .eslintrc.json - ESLint configuration
- .stylelintrc.json - Stylelint configuration
- ESLint Setup Guide - ESLint version compatibility
- Documentation Style Guide - Documentation standards
- Obsidian API Documentation