Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628// src/plugin/plugin.tsimport { Component, isComponentLoaded, runCleanupActions } from './component'
// Re-export for internal use by testsexport { 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<string>()
/** * 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<any>[] = []
/** 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> | 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<T>` 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<any>): 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:<plugin-id>'`. * * 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<any> | 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<Promise<void>> = [] 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: `<plugin-id>` or `<plugin-id>:<action>`) * @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<any> { 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<void> { 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 }}