// src/plugin/plugin.ts import { Component, isComponentLoaded, runCleanupActions } from './component' // Re-export for internal use by tests export { isComponentLoaded } import type { App } from './app' import type { PluginManifest, Command, ViewCreator, MarkdownPostProcessor, MarkdownPostProcessorContext } from './types' import type { PluginSettingTab } from './settings/setting-tab' import type { FormattingAction } from './ui/formatting-action-types' import type { EditorSuggest } from './editor-suggest' import type { Extension } from '@codemirror/state' import type { ConstructSpec } from '../format-preservation' import type { HoverLinkSource, ObsidianProtocolHandler, CliFlags, CliHandler, BasesViewRegistration } from './obsidian-stubs' import { registerPostProcessor, registerCodeBlockProcessor, clearPostProcessorsForPlugin, enhanceRenderElement, invokeCodeBlockProcessor, } from './markdown-renderer-impl' import { contentTypeFromExtension, KnownContentTypes } from '../types/content-types' // RegisteredCommand type reserved for future command registration enhancements // import type { RegisteredCommand } from './commands/command-registry' /** * Warn-once set for registerObsidianProtocolHandler. * Keys are `registerObsidianProtocolHandler:${action}`. */ const warnedProtocolHandlers = new Set() /** * Reset warn-once state for Plugin stubs (for testing only). */ export function _resetPluginWarnedStubs(): void { warnedProtocolHandlers.clear() } /** * Plugin base class. All plugins extend this. * * ## Storage Mechanisms * * loadData/saveData use the host's hidden synced vault state in a running app. * No device-local plugin data is read or written through this API. */ export abstract class Plugin extends Component { /** * App instance. * @public */ app: App /** * Plugin manifest. * @public */ manifest: PluginManifest /** View types registered by this plugin (for cleanup) */ private registeredViewTypes: string[] = [] /** EditorSuggests registered by this plugin (for cleanup, mirrors registeredViewTypes). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private registeredEditorSuggests: EditorSuggest[] = [] /** Registered post processors */ private postProcessors: MarkdownPostProcessor[] = [] /** Setting tabs */ private settingTabs: PluginSettingTab[] = [] /** Hover link source IDs registered by this plugin (for cleanup) */ private registeredHoverLinkSources: string[] = [] /** Content-type → view-type bindings registered by this plugin (for cleanup) */ private registeredContentTypeBindings: Array<{ contentType: string; viewType: string }> = [] constructor(app: App, manifest: PluginManifest) { super() this.app = app this.manifest = manifest // Real Obsidian plugins sometimes override unload() directly without // calling super.unload() (e.g. Periodic Notes). To ensure our cleanup // always runs, we wrap the subclass's unload method to guarantee // cleanupPluginResources + Component.unload are called. const self = this const subclassUnload = this.unload.bind(this) const baseUnload = Component.prototype.unload.bind(this) const cleanup = () => this.cleanupPluginResources() this.unload = function wrappedUnload() { if (!isComponentLoaded(self)) return // Component.unload() is idempotent if the subclass already called it. runCleanupActions([cleanup, subclassUnload, baseUnload]) } } /** * Clean up plugin resources from App managers. */ private cleanupPluginResources(): void { runCleanupActions([ () => this.app.commands.removePluginCommands(this.manifest.id), () => this.app.hotkeys.removePluginHotkeys(this.manifest.id), () => this.app.reamsRibbon.removePluginIcons(this.manifest.id), () => this.app.reamsStatusBar.removePluginItems(this.manifest.id), () => this.app.reamsFormattingActions.removePluginActions(this.manifest.id), ...this.registeredViewTypes.splice(0).map(type => () => this.app.workspace.unregisterViewType(type)), ...this.registeredEditorSuggests.splice(0).map(s => () => this.app.workspace.unregisterEditorSuggest(s)), () => this.app.workspace.unregisterEditorExtensions(this.manifest.id), () => clearPostProcessorsForPlugin(this.manifest.id), () => { this.postProcessors = [] }, () => { this.settingTabs = [] }, ...this.registeredHoverLinkSources.splice(0).map(id => () => this.app.workspace.unregisterHoverLinkSource(id)), ...this.registeredContentTypeBindings.splice(0).map(({ contentType, viewType }) => () => this.app.workspace.unregisterViewTypeForContentType(contentType, viewType)), () => this.app.unregisterConstructs(this.manifest.id), ]) } /** * Called when the plugin is loaded. * Override to perform initialization. */ onload(): Promise | void { // Override in subclass } /** * Add a ribbon icon to the left bar. * Returns a detached element if the plugin is dead. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => any): HTMLElement { if (!isComponentLoaded(this)) { return document.createElement('div') } this.app.reamsRibbon.addIcon(this.manifest.id, icon, title, callback) // Return a detached element to maintain Obsidian API compatibility. // The actual icon is rendered by React from the manager's snapshot. return document.createElement('div') } /** * Add a status bar item. * Returns an HTMLElement with Obsidian helper methods like setText(). * Returns a detached element (with stub methods) if the plugin is dead. */ addStatusBarItem(): HTMLElement { if (!isComponentLoaded(this)) { // Return a stub element with the expected methods. // Uses Object.assign to avoid double type assertion through unknown. return Object.assign(document.createElement('div'), { setText() {}, }) } return this.app.reamsStatusBar.addItem(this.manifest.id, this.manifest.name) } /** * Add a formatting action to the toolbar. * No-ops if the plugin is dead. * * @internal Reams-specific — not part of upstream Obsidian API. */ protected addFormattingAction(action: FormattingAction): void { if (!isComponentLoaded(this)) return this.app.reamsFormattingActions.register(this.manifest.id, action) } /** * Remove a formatting action from the toolbar. * Only removes actions owned by this plugin. * * @internal Reams-specific — not part of upstream Obsidian API. */ protected removeFormattingAction(actionId: string): void { this.app.reamsFormattingActions.unregisterOwned(this.manifest.id, actionId) } /** * Register a command. * The command ID is prefixed with the plugin ID. * If the command has default hotkeys, they are registered. * * Returns the registered command. Note: Internally this is a `RegisteredCommand` * (with prefixed `id`), but the return type is `Command` for Obsidian API compat. * * @throws Error if the plugin is not loaded. Per AGENTS.md: "throw clear * errors for unimplemented APIs" — calling addCommand on a dead plugin * is a programming error that should surface immediately rather than * silently returning a stub. */ addCommand(command: Command): Command { if (!isComponentLoaded(this)) { throw new Error( `Plugin "${this.manifest.id}" is not loaded. Cannot call addCommand() after unload.` ) } // Obsidian treats duplicate command IDs as replacement, not accumulation. this.app.hotkeys.removeHotkey(`${this.manifest.id}:${command.id}`) const registered = this.app.commands.addCommand(this.manifest.id, command) // Register default hotkeys if (command.hotkeys) { for (const hotkey of command.hotkeys) { const conflict = this.app.hotkeys.registerHotkey(registered.id, hotkey) if (conflict) { console.warn( `Hotkey conflict: ${registered.id} cannot use hotkey already bound to ${conflict}` ) } } } return registered } /** * Remove a command. */ removeCommand(commandId: string): void { const fullId = `${this.manifest.id}:${commandId}` this.app.commands.removeCommand(fullId) this.app.hotkeys.removeHotkey(fullId) } /** * Register a setting tab. * No-ops if the plugin is dead. */ addSettingTab(settingTab: PluginSettingTab): void { if (!isComponentLoaded(this)) return this.settingTabs.push(settingTab) } /** * Get registered setting tabs. * Used by PluginLoader to render settings UI. * * @internal Host-internal. Used by SettingsModal to enumerate and render * plugin setting tabs. Plugins register tabs via `addSettingTab()` and * don't need to query this list. */ getSettingTabs(): PluginSettingTab[] { return this.settingTabs } /** * Register a view type. * No-ops if the plugin is dead. */ registerView(type: string, viewCreator: ViewCreator): void { if (!isComponentLoaded(this)) return this.app.workspace.registerViewType(type, viewCreator) this.registeredViewTypes.push(type) } /** * Bind a content type (UTI-style identifier) to one of this plugin's * registered view types. The binding is tracked and torn down on plugin * unload. * * Mirrors {@link registerView} lifecycle: * - No-ops silently if the plugin is dead. * - Throws if `viewType` was not registered via {@link registerView}. * * @internal Reams-novel API used by content-type → view-type routing (#406). * Not part of upstream Obsidian's Plugin surface. */ registerViewForContentType(contentType: string, viewType: string): void { if (!isComponentLoaded(this)) return this.app.workspace.registerViewTypeForContentType(contentType, viewType) this.registeredContentTypeBindings.push({ contentType, viewType }) } /** * Register an `EditorSuggest` so it participates in editor autocomplete. * * Mirrors Obsidian's `Plugin.registerEditorSuggest` API. Once registered, * the suggester's `onTrigger(cursor, editor, file)` is consulted on every * keystroke; matching suggesters compete by longest-match for the active * range, the dispatcher renders winners' items via `renderSuggestion`, * and on selection it sets `this.context` to the live * `EditorSuggestContext` (start, end, query, editor, file, signal — the * latter being a Reams extension to Obsidian's type) before invoking * `selectSuggestion(item, evt)` and clearing it back to null. * * The registration is tracked on this Plugin and automatically removed * on unload (see {@link cleanupPluginResources}). No-ops silently if the * plugin is unloaded. * * Contract: {@link EditorSuggest} (`src/plugin/editor-suggest.ts`). * Validated end-to-end against `obsidian-emoji-shortcodes` v2.2.0 in * `tests/e2e/emoji-shortcodes-plugin.e2e.test.ts`. * * Markdown-only API per the typed-view host contract: suggesters only * fire inside the markdown CM6 mount. See {@link * TypedViewHostContract} (invariant 5) in * `src/plugin/views/typed-view-host-contract.ts`. * * @param suggest An EditorSuggest subclass instance. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any registerEditorSuggest(suggest: EditorSuggest): void { if (!isComponentLoaded(this)) return // Delegate to workspace (which deduplicates its own registry) this.app.workspace.registerEditorSuggest(suggest) // Also deduplicate our tracking array so unload doesn't call // unregisterEditorSuggest multiple times for the same suggester if (!this.registeredEditorSuggests.includes(suggest)) { this.registeredEditorSuggests.push(suggest) } } /** * Register a CM6 extension that will be added to the editor. * * Matches Obsidian's Plugin.registerEditorExtension(extension) signature. * The extension is automatically removed when the plugin unloads. * * Multiple calls accumulate extensions. No conflict resolution if plugins * register competing decorations — last-registered wins (matches Obsidian). * * Markdown-only API per the typed-view host contract: the extension is * applied to the markdown CM6 `EditorView` only. Non-markdown views own * no such mount. See {@link TypedViewHostContract} (invariant 5) in * `src/plugin/views/typed-view-host-contract.ts`. * * @param extension A CM6 Extension (StateField, ViewPlugin, Facet, etc.) */ registerEditorExtension(extension: Extension): void { if (!isComponentLoaded(this)) return this.app.workspace.registerEditorExtension(this.manifest.id, extension) } /** * Register a format-preservation construct spec. * * The spec is validated at registration time. Duplicate names (including * core reserved names) are rejected. Plugin specs are capped at * maxClaimRange ≤ 256 and spliceScopeOutsideMark ≤ 256. The spec's marks * will carry `owner: 'plugin:'`. * * On plugin unload, the spec is automatically unregistered. Existing marks * in documents are preserved and treated as opaque until the plugin reloads. * * @param spec The ConstructSpec defining the construct's behavior. */ registerConstruct(spec: ConstructSpec): void { if (!isComponentLoaded(this)) return this.app.registerConstruct(this.manifest.id, spec) } /** * Legacy CM5 hook. Obsidian historically called the callback once per * existing CM5 instance, then again whenever a new editor was created. * * Real Obsidian preserves this method on Plugin for backward compat but * no-ops it under CM6 (there are no CM5 instances). Plugins that still * call it (e.g. cm-typewriter-scroll-obsidian) typically also register a * CM6 extension via registerEditorExtension; the CM6 path is what * actually applies their behavior in modern Obsidian. * * We accept the callback but never invoke it. Don't throw — that would * break plugins whose onload() calls this before falling through to CM6 * registration. * * @internal Legacy compat stub — not part of upstream Obsidian's public * API surface (CM5 methods are deprecated). Kept for plugins that still * call it during onload(). */ registerCodeMirror(_callback: (cm: unknown) => void): void { // No-op: CM5 is not available. The plugin's CM6 path runs through // registerEditorExtension(). See plugin-loader Workspace.iterateCodeMirrors. } /** * Registers a view with the 'Page preview' core plugin as an emitter of * the 'hover-link' event. * * No-op in Reams — page preview is not implemented. Exists for Obsidian * API compatibility. The registration is tracked and cleaned up on unload. * * @param id - Unique identifier for this hover link source * @param info - Configuration for the hover link source */ registerHoverLinkSource(id: string, info: HoverLinkSource): void { if (!isComponentLoaded(this)) return this.app.workspace.registerHoverLinkSource(id, info) // Track for cleanup, deduplicating in case of repeated calls with same id if (!this.registeredHoverLinkSources.includes(id)) { this.registeredHoverLinkSources.push(id) } } /** * Register a markdown post processor. * * Post-processors are invoked by MarkdownRenderer.render() on each top-level * block after the markdown is parsed to HTML. They can modify the DOM, add * event listeners, or attach MarkdownRenderChild instances via ctx.addChild(). * * Markdown-only API per the typed-view host contract: post-processors * run inside `MarkdownRenderer.render()` only. See {@link * TypedViewHostContract} (invariant 5) in * `src/plugin/views/typed-view-host-contract.ts`. * * @param postProcessor - Function to run on each rendered block * @param sortOrder - Optional sort order (lower numbers run first, default 0) * @returns The processor (for chaining or later removal) */ registerMarkdownPostProcessor(postProcessor: MarkdownPostProcessor, sortOrder?: number): MarkdownPostProcessor { if (!isComponentLoaded(this)) return postProcessor // Track locally for plugin lifecycle this.postProcessors.push(postProcessor) // Register in global registry so MarkdownRenderer.render() can invoke it registerPostProcessor(this.manifest.id, postProcessor, sortOrder ?? 0) return postProcessor } /** * Register a handler for the obsidian:// protocol. * * NOTE: This is a no-op in Reams. Reams is a web app and does not support * custom protocol handlers. Plugins like QuickAdd that register protocol * handlers will not receive those callbacks. */ registerObsidianProtocolHandler( action: string, _handler: ObsidianProtocolHandler ): void { // Warn once per action to alert plugin authors const key = `registerObsidianProtocolHandler:${action}` if (!warnedProtocolHandlers.has(key)) { warnedProtocolHandlers.add(key) console.warn( `[Reams] Plugin.registerObsidianProtocolHandler('${action}') is not supported. ` + `Reams is a web app and cannot handle custom protocol URIs.` ) } } /** * Register a markdown code block processor for a specific language. * * When MarkdownRenderer.render() encounters a fenced code block with the * specified language, this handler is invoked with the code source and a * container element to render into. * * Markdown-only API per the typed-view host contract — see {@link * registerMarkdownPostProcessor} and {@link TypedViewHostContract} * (invariant 5) in `src/plugin/views/typed-view-host-contract.ts`. * * @param language - The code fence language (e.g., 'mermaid', 'dataview') * @param handler - Function to render the code block * @param sortOrder - Optional sort order (lower numbers run first, default 0) * @returns The underlying MarkdownPostProcessor (for chaining or removal) */ registerMarkdownCodeBlockProcessor( language: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => Promise | void, sortOrder?: number ): MarkdownPostProcessor { // Sanitize language to prevent CSS selector injection. // Only allow alphanumeric, hyphens, and underscores (valid CSS class characters). // This prevents injection like 'foo, script' from selecting unintended elements. const safeLanguage = language.replace(/[^a-zA-Z0-9_-]/g, '') if (safeLanguage !== language) { console.warn( `[Reams] registerMarkdownCodeBlockProcessor: language "${language}" contains invalid characters, sanitized to "${safeLanguage}"` ) } // Reject empty / all-invalid input — the resulting `pre > code.language-` // selector is invalid CSS and would throw a SyntaxError on every render. // Returns a sentinel no-op processor (NOT registered) so callers don't // crash if they invoke the return value; treat it as an error indicator. if (safeLanguage.length === 0) { console.warn( `[Reams] registerMarkdownCodeBlockProcessor: language "${language}" is empty after sanitization; refusing registration. The returned function is a non-registered sentinel no-op.` ) const noop: MarkdownPostProcessor = () => {} return noop } const pluginId = this.manifest.id // The processor itself is async so renderMarkdownToElement awaits it. // We invoke handler in parallel across all code blocks of the matching language, // attaching error handlers so a single rejection doesn't break the chain. const processor: MarkdownPostProcessor = async (el, ctx) => { const codeBlocks = el.querySelectorAll(`pre > code.language-${safeLanguage}`) const tasks: Array> = [] for (const block of codeBlocks) { const source = block.textContent ?? '' const container = document.createElement('div') enhanceRenderElement(container) block.parentElement?.replaceWith(container) tasks.push(invokeCodeBlockProcessor({ pluginId, language: safeLanguage, handler, source, el: container, ctx, }).then(() => undefined)) } if (tasks.length > 0) { await Promise.all(tasks) } } if (!isComponentLoaded(this)) { console.warn( `[Reams] Plugin "${pluginId}" called registerMarkdownCodeBlockProcessor("${safeLanguage}") while not loaded. The returned processor is NOT registered globally and will never be invoked.` ) return processor } // Track locally for plugin lifecycle this.postProcessors.push(processor) // Register in global registry registerCodeBlockProcessor(this.manifest.id, safeLanguage, processor, handler, sortOrder ?? 0) return processor } /** * Register file extensions to be associated with a specific view type. * * Bridges Obsidian's extension-keyed registration to Reams' content-type * routing: each known extension is translated through the taxonomy * ({@link contentTypeFromExtension}) and forwarded to * {@link registerViewForContentType}. Unknown extensions silently no-op. * * @param extensions - Array of file extensions (without dots) * @param viewType - The view type to associate with these extensions */ registerExtensions(extensions: string[], viewType: string): void { if (!isComponentLoaded(this)) return for (const ext of extensions) { const ct = contentTypeFromExtension(ext) if (ct === KnownContentTypes.Data) { // Unknown extension — nothing to bind. continue } this.registerViewForContentType(ct, viewType) } } /** * Register a custom Bases view. * * NOTE: This is a no-op stub in Reams. Reams does not yet support * the Bases (database views) feature. * * @param viewId - Unique identifier for the view * @param registration - View registration configuration * @returns false (registration failed — feature not implemented) */ registerBasesView(_viewId: string, _registration: BasesViewRegistration): boolean { // No-op stub — Bases feature not yet implemented in Reams return false } /** * Register a CLI handler for the Obsidian CLI. * * NOTE: This is a no-op stub in Reams. Reams is a web app and does not * support CLI integration. * * @param command - Command ID (format: `` or `:`) * @param description - Description shown in CLI help * @param flags - CLI flag definitions (or null) * @param handler - Handler function */ registerCliHandler( _command: string, _description: string, _flags: CliFlags | null, _handler: CliHandler ): void { // No-op stub — CLI not available in Reams web app } /** * Load plugin data from storage. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async loadData(): Promise { if (!this.app.reamsStateData) throw new Error('Vault state is unavailable for plugin data') return this.app.reamsStateData.load('plugin-data', this.manifest.id) } /** * Save plugin data to storage. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async saveData(data: any): Promise { if (!this.app.reamsStateData) throw new Error('Vault state is unavailable for plugin data') await this.app.reamsStateData.save('plugin-data', this.manifest.id, data) } /** * Called when user explicitly enables the plugin. * Safe to show UI at this point. */ onUserEnable(): void { // Override in subclass } /** * Called when the `data.json` file is modified on disk externally from Obsidian. * This usually means that a Sync service or external program has modified * the plugin settings. * * Implement this method to reload plugin settings when they have changed externally. * * NOTE: Reams does not implement file-watching for plugin data. This method is * provided for Obsidian API compatibility. Plugins can override it, but the * host will not automatically invoke it. Plugins using sync services should * implement their own change detection if needed. * * @public * @since 1.5.7 */ onExternalSettingsChange(): any { // Default no-op; subclasses override to handle external settings changes } }