| name | nova-patterns |
| description | Nova plugin coding standards, compliance rules, and design patterns. Manually maintained reference for development. |
Nova Development Patterns
Nova is an AI writing plugin for Obsidian that enables direct, in-place editing. This skill documents its coding standards, compliance rules, and design patterns.
For roadmap, spec, pricing, or feature-prioritization work, pair this skill with nova-product.
Core Philosophy
- Direct document manipulation: Edits happen in documents, not external interfaces
- Surgical precision: AI edits exactly where users specify
- Direct injection: Components receive dependencies via constructors
- Privacy-first: Local AI emphasis, user controls their API keys
- Streaming-first: All AI operations support streaming for responsive UX
File Header Standard
All TypeScript files MUST have a standardized header comment:
This enables automated codebase documentation. Run /project:sync-codebase after adding new files.
Communication Patterns
Nova uses several communication patterns depending on the relationship between components:
Constructor Injection (primary pattern)
Components receive their dependencies via constructor parameters. This is the default for tightly-coupled components.
this.promptBuilder = new PromptBuilder(this.documentEngine, this.conversationManager);
this.documentEngine = new DocumentEngine(this.app, this.conversationManager);
Direct Method Calls
Tightly-coupled components call methods on each other directly. Decoupling is a case-by-case decision, not a blanket rule.
const conversation = this.conversationManager.getConversation(file);
const context = this.documentEngine.getDocumentContext(editor, file);
Obsidian Workspace Events
Cross-plugin and layout-level communication uses Obsidian's built-in workspace events.
this.app.workspace.on('file-open', (file) => this.handleFileOpen(file));
this.app.workspace.on('layout-change', () => this.handleLayoutChange());
Custom DOM Events
Lightweight cross-component notifications (e.g., settings changes, license updates) use CustomEvent on document.
document.dispatchEvent(new CustomEvent('nova-provider-configured', { detail: { provider } }));
this.registerDomEvent(document, 'nova-provider-configured', this.handleProviderConfigured.bind(this));
Component Patterns
UI Components (src/ui/)
export class MyComponent {
private plugin: NovaPlugin;
private containerEl: HTMLElement;
constructor(plugin: NovaPlugin, containerEl: HTMLElement) {
this.plugin = plugin;
this.containerEl = containerEl;
}
async init(): Promise<void> {
this.buildUI();
this.registerEvents();
}
private buildUI(): void {
const header = this.containerEl.createEl('div', { cls: 'nova-header' });
header.setText('Title');
}
private registerEvents(): void {
this.plugin.registerDomEvent(this.containerEl, 'click', (e) => {
this.handleClick(e);
});
}
destroy(): void {
}
}
Core Services (src/core/)
Services handle business logic and are injected into consumers via constructors:
export class MyService {
constructor(private app: App, private conversationManager: ConversationManager) {
}
async init(): Promise<void> {
}
async performAction(params: ActionParams): Promise<Result> {
try {
const result = await this.doWork(params);
return result;
} catch (error) {
Logger.error('Action failed', { error, params });
throw error;
}
}
}
AI Providers (src/ai/providers/)
All providers implement a common interface:
interface AIProvider {
name: string;
generateResponse(
messages: ConversationMessage[],
options: GenerationOptions
): AsyncGenerator<StreamingResponse>;
getModelInfo(): ModelInfo;
validateApiKey(): Promise<boolean>;
getContextLimit(): number;
}
async *generateResponse(messages, options) {
for await (const chunk of this.callAPI(messages)) {
yield {
type: 'content',
content: chunk.text,
finished: chunk.done
};
}
}
Timer Management
Nova uses TimeoutManager for Obsidian-compliant timeout handling:
import { TimeoutManager } from '../utils/timeout-manager';
setTimeout(() => this.doSomething(), 1000);
TimeoutManager.addTimeout(
this.plugin,
() => this.doSomething(),
1000,
'optional-id-for-cancellation'
);
TimeoutManager.clearTimeout('optional-id-for-cancellation');
this.plugin.registerInterval(
window.setInterval(() => this.poll(), 5000)
);
Logging
Use the Logger utility, never console.log:
import { Logger } from '../utils/logger';
Logger.debug('Detailed info', { context });
Logger.info('Normal operation', { data });
Logger.warn('Potential issue', { warning });
Logger.error('Failed operation', { error, context });
Error Handling Pattern
async performRiskyOperation(): Promise<void> {
try {
await this.riskyCall();
} catch (error) {
Logger.error('Operation failed', {
error,
operation: 'riskyCall',
context: this.getContext()
});
new Notice('Something went wrong. Please try again.');
throw error;
}
}
File Conventions
| Type | Convention | Example |
|---|
| Classes | PascalCase | StreamingManager |
| Interfaces | PascalCase, prefix I optional | AIProvider or ISettings |
| Functions/Methods | camelCase | handleClick() |
| Variables | camelCase | currentMessage |
| Constants | SCREAMING_SNAKE | MAX_CONTEXT_TOKENS |
| Files | kebab-case | streaming-manager.ts |
| CSS Classes | BEM-ish with nova prefix | nova-sidebar__header |
Testing Patterns
Location: test/
describe('ComponentName', () => {
let component: ComponentName;
let mockPlugin: jest.Mocked<NovaPlugin>;
beforeEach(() => {
mockPlugin = createMockPlugin();
component = new ComponentName(mockPlugin);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should persist conversation state between sessions', async () => {
const conversation = createTestConversation();
await component.saveConversation(conversation);
const loaded = await component.loadConversation(conversation.id);
expect(loaded).toEqual(conversation);
});
it('should call saveData method', () => { });
});
Mock Patterns
See test/__mocks__/ for consistent Obsidian API mocks:
obsidian.ts - Core Obsidian mocks
workspace.ts - Workspace and view mocks
vault.ts - File system mocks
Intent Detection
The IntentDetector classifies user input into categories:
type Intent =
| 'CONTENT'
| 'METADATA'
| 'CHAT'
| 'COMMAND';
Streaming Infrastructure
The StreamingManager handles real-time text generation:
await streamingManager.streamToEditor(
aiStream,
editor,
{
startPosition: cursor,
enableAutoScroll: true,
onError: (error) => this.handleStreamError(error)
}
);
Constants
Magic strings and selectors should go in src/constants.ts (not yet consistently applied across the codebase):
import { CSS_CLASSES, TIMEOUTS } from '../constants';
element.addClass(CSS_CLASSES.SIDEBAR_HEADER);
element.addClass('nova-sidebar-header');
See also: .claude/skills/nova-codebase/SKILL.md for current file structure and exports.