@openforge-app/plugin-sdk 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,26 +1,238 @@
1
1
  # @openforge-app/plugin-sdk
2
2
 
3
- Public SDK for building OpenForge plugins. [`docs/plugin-authoring.md`](../../docs/plugin-authoring.md) is the canonical guide for the package, runtime, Svelte-sharing, and CSS-loading contract.
3
+ TypeScript SDK for building trusted OpenForge plugins. It includes API version 1 types, frontend and backend entry-point helpers, package metadata validation, testing fakes, Vite helpers, and shared Svelte components.
4
4
 
5
- Styled frontend plugins must declare Vite/Svelte's emitted CSS artifacts in `package.json#openforge.frontendStyles`:
5
+ OpenForge plugins run trusted code. Install plugins only from authors you trust.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @openforge-app/plugin-sdk
11
+ ```
12
+
13
+ Frontend plugins also use the Svelte 5 runtime supplied by OpenForge. Add Svelte as a peer dependency and as a development dependency in the plugin package:
14
+
15
+ ```sh
16
+ pnpm add -D svelte@^5 @sveltejs/vite-plugin-svelte vite
17
+ ```
18
+
19
+ ```json
20
+ {
21
+ "peerDependencies": {
22
+ "svelte": "^5.0.0"
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## Package metadata
28
+
29
+ Declare the plugin in `package.json#openforge` and publish every built file named by the metadata:
6
30
 
7
31
  ```json
8
32
  {
33
+ "name": "@acme/openforge-notes",
34
+ "version": "1.0.0",
35
+ "type": "module",
36
+ "files": ["dist"],
37
+ "dependencies": {
38
+ "@openforge-app/plugin-sdk": "latest"
39
+ },
40
+ "peerDependencies": {
41
+ "svelte": "^5.0.0"
42
+ },
9
43
  "openforge": {
44
+ "id": "acme.notes",
45
+ "apiVersion": 1,
46
+ "displayName": "Notes",
47
+ "description": "Project notes",
48
+ "icon": "notebook-text",
10
49
  "frontend": "./dist/frontend.js",
11
- "frontendStyles": ["./dist/plugin.css"]
50
+ "frontendStyles": ["./dist/plugin.css"],
51
+ "backend": "./dist/backend.mjs",
52
+ "requires": ["views", "storage", "backend"]
12
53
  }
13
54
  }
14
55
  ```
15
56
 
16
- The host validates, attaches, reloads, and removes these package-relative stylesheets with the frontend plugin lifecycle.
57
+ `openforge.apiVersion` controls compatibility with the OpenForge host. It is separate from the npm package version. The current host contract uses API version `1`.
58
+
59
+ OpenForge loads built artifacts from the installed package. It does not compile plugin source during installation.
60
+
61
+ ## Frontend entry point
62
+
63
+ Use `@openforge-app/plugin-sdk/frontend` for renderer contributions such as views, task UI, settings, commands, and events:
64
+
65
+ ```ts
66
+ import { defineFrontendPlugin } from '@openforge-app/plugin-sdk/frontend'
67
+
68
+ export default defineFrontendPlugin({
69
+ activate(openforge, context) {
70
+ context.subscriptions.add(openforge.views.register({
71
+ id: 'notes',
72
+ title: 'Notes',
73
+ icon: 'notebook-text',
74
+ placement: 'rail',
75
+ component: () => import('./NotesView.svelte'),
76
+ }))
77
+ },
78
+ })
79
+ ```
80
+
81
+ Add every registration and cleanup handle to `context.subscriptions`. OpenForge disposes them when it deactivates or reloads the plugin.
82
+
83
+ Task reads support project-filtered invalidation subscriptions. Notifications may be coalesced and contain identity plus a reason, not a Task snapshot. Repeat only the bounded read your view needs:
84
+
85
+ ```ts
86
+ export default defineFrontendPlugin({
87
+ activate(openforge, context) {
88
+ const projectId = openforge.context.getSnapshot().projectId
89
+ if (!projectId) return
90
+
91
+ context.subscriptions.add(openforge.tasks.onDidChange(projectId, (event) => {
92
+ const refresh = event.taskId
93
+ ? openforge.tasks.detail(event.projectId, event.taskId)
94
+ : openforge.tasks.active(event.projectId)
95
+ void refresh.then((result) => {
96
+ // Update plugin-owned state from the bounded result.
97
+ }).catch(console.error)
98
+ }))
99
+ },
100
+ })
101
+ ```
102
+
103
+ The reason is one of `created`, `updated`, `completed`, `attention`, or `execution`. Add the returned disposable to `context.subscriptions`; explicit disposal, deactivation, reload, and uninstall then stop delivery.
104
+
105
+ Frontend bundles must share Svelte with the host. Use the SDK's Vite external helper:
106
+
107
+ ```ts
108
+ import { svelte } from '@sveltejs/vite-plugin-svelte'
109
+ import { openforgePluginViteExternals } from '@openforge-app/plugin-sdk/vite'
110
+ import { defineConfig } from 'vite'
111
+
112
+ export default defineConfig({
113
+ plugins: [svelte()],
114
+ build: {
115
+ lib: {
116
+ entry: 'src/frontend.ts',
117
+ formats: ['es'],
118
+ fileName: 'frontend',
119
+ },
120
+ rollupOptions: {
121
+ external: openforgePluginViteExternals,
122
+ },
123
+ },
124
+ })
125
+ ```
126
+
127
+ Svelte library builds emit CSS separately. List each emitted CSS file in `openforge.frontendStyles`; otherwise OpenForge will not load it.
128
+
129
+ ## Backend entry point
130
+
131
+ Use `@openforge-app/plugin-sdk/backend` for Node dependencies, plugin-local RPC methods, and background services:
132
+
133
+ ```ts
134
+ import { defineBackendPlugin } from '@openforge-app/plugin-sdk/backend'
135
+
136
+ export default defineBackendPlugin({
137
+ activate(openforge, context) {
138
+ context.subscriptions.add(openforge.backend.registerMethod('ping', {
139
+ input: { type: 'object', additionalProperties: false },
140
+ output: {
141
+ type: 'object',
142
+ required: ['ok'],
143
+ properties: { ok: { type: 'boolean' } },
144
+ },
145
+ handler: async () => ({ ok: true }),
146
+ }))
147
+ },
148
+ })
149
+ ```
150
+
151
+ Backend plugins run in the trusted Node plugin host. They cannot register Svelte views, task UI, settings sections, or frontend navigation.
152
+
153
+ ## Testing
154
+
155
+ `@openforge-app/plugin-sdk/testing` provides mock APIs and a registry fake for registration, capability, storage, and cleanup tests:
156
+
157
+ ```ts
158
+ import { describe, expect, it } from 'vitest'
159
+ import { createOpenForgeRegistryFake } from '@openforge-app/plugin-sdk/testing'
160
+ import plugin from '../src/frontend'
161
+
162
+ describe('frontend plugin', () => {
163
+ it('registers its view', async () => {
164
+ const registry = createOpenForgeRegistryFake({
165
+ pluginId: 'acme.notes',
166
+ projectId: 'P-1',
167
+ })
168
+
169
+ await registry.activateFrontend(plugin)
170
+
171
+ expect(registry.snapshot.views).toMatchObject([
172
+ { id: 'notes', qualifiedId: 'acme.notes.notes', title: 'Notes' },
173
+ ])
174
+ })
175
+ })
176
+ ```
177
+
178
+ Use `registry.emitTaskChange(...)` to drive invalidation behavior without importing host events or renderer stores:
179
+
180
+ ```ts
181
+ registry.emitTaskChange({
182
+ projectId: 'P-1',
183
+ taskId: 'T-42',
184
+ reason: 'updated',
185
+ })
186
+ ```
187
+
188
+ ## Public entry points
189
+
190
+ The package exports these modules:
191
+
192
+ | Import | Purpose |
193
+ | --- | --- |
194
+ | `@openforge-app/plugin-sdk` | Shared types, metadata validation, constants, and domain helpers |
195
+ | `@openforge-app/plugin-sdk/frontend` | Frontend plugin definition and renderer types |
196
+ | `@openforge-app/plugin-sdk/backend` | Backend plugin definition and Node-host types |
197
+ | `@openforge-app/plugin-sdk/testing` | Mock APIs, storage, subscriptions, and registry fakes |
198
+ | `@openforge-app/plugin-sdk/vite` | Host-runtime externals for plugin builds |
199
+ | `@openforge-app/plugin-sdk/package-metadata-schema.json` | JSON Schema for `package.json#openforge` |
200
+ | `@openforge-app/plugin-sdk/domain` | Shared OpenForge domain types |
201
+ | `@openforge-app/plugin-sdk/prStatusPresentation` | Pull request status presentation helpers |
202
+ | `@openforge-app/plugin-sdk/markdown` | Markdown rendering helpers |
203
+ | `@openforge-app/plugin-sdk/numberParsing` | Numeric parsing helpers |
204
+ | `@openforge-app/plugin-sdk/projectFileTree` | Project file tree helpers |
205
+ | `@openforge-app/plugin-sdk/sanitize` | HTML and SVG sanitization helpers |
206
+ | `@openforge-app/plugin-sdk/pluginIcons` | Plugin icon validation and sanitization |
207
+ | `@openforge-app/plugin-sdk/fileIcons` | File-type icon lookup helpers |
208
+ | `@openforge-app/plugin-sdk/collapsibleSectionState` | Persisted collapse-state helpers |
209
+ | `@openforge-app/plugin-sdk/taskBrowserDevToolsShortcuts` | Task Browser DevTools shortcut helpers |
210
+
211
+ Shared Svelte components use explicit imports. The package exports:
212
+
213
+ - `@openforge-app/plugin-sdk/ui/Button.svelte`
214
+ - `@openforge-app/plugin-sdk/ui/Checkbox.svelte`
215
+ - `@openforge-app/plugin-sdk/ui/CollapsibleSection.svelte`
216
+ - `@openforge-app/plugin-sdk/ui/FileTypeIcon.svelte`
217
+ - `@openforge-app/plugin-sdk/ui/MarkdownContent.svelte`
218
+ - `@openforge-app/plugin-sdk/ui/Modal.svelte`
219
+ - `@openforge-app/plugin-sdk/ui/PluginPageHeader.svelte`
220
+ - `@openforge-app/plugin-sdk/ui/PluginPageShell.svelte`
221
+ - `@openforge-app/plugin-sdk/ui/PluginSidebarLink.svelte`
222
+ - `@openforge-app/plugin-sdk/ui/PluginViewState.svelte`
223
+ - `@openforge-app/plugin-sdk/ui/ProjectFileTree.svelte`
224
+ - `@openforge-app/plugin-sdk/ui/ResizablePanel.svelte`
225
+ Use only documented package exports. Do not import OpenForge renderer stores, Electron or preload APIs, Rust internals, app IPC wrappers, or files under this package's `src/` directory.
226
+
227
+ ## Documentation
17
228
 
18
- ## Publishing
229
+ - [Plugin authoring guide](https://github.com/koenvg/openforge/blob/main/docs/plugin-authoring.md)
230
+ - [SDK reference](https://github.com/koenvg/openforge/blob/main/docs/plugins/sdk-reference.md)
19
231
 
20
- Automated releases use npm trusted publishing rather than a long-lived npm token. Configure the package's npm Trusted Publisher with repository `koenvg/openforge`, workflow `publish-plugin-sdk.yml`, and environment `npm-publish`. The workflow is the caller npm validates even though the shared publish steps live in `reusable-publish-plugin-sdk.yml`. For a failed tag release, dispatch this workflow manually with the missing `package_version` and disable dry-run.
232
+ The authoring guide documents package metadata, capabilities, frontend and backend runtime boundaries, storage, task APIs, browser surfaces, and CSS loading. The SDK reference lists the public types and shared UI components.
21
233
 
22
234
  ## License
23
235
 
24
- `@openforge-app/plugin-sdk` is licensed under the MIT License. Plugin authors may use it for personal, internal, open source, or commercial plugin development, including redistribution and modification under the MIT terms.
236
+ The SDK is available under the [MIT License](./LICENSE). Plugins may use, modify, and redistribute it under those terms, including in commercial and private plugins.
25
237
 
26
- This MIT license applies to the SDK package only. The OpenForge desktop application is licensed separately under the repository root `LICENSE` and is source-available/proprietary; the app may not be commercially resold or redistributed without permission.
238
+ The OpenForge desktop application has a separate license. The SDK license does not grant permission to commercially resell or redistribute the app.
package/dist/domain.d.ts CHANGED
@@ -14,8 +14,10 @@ export type WritableBoardStatus = Exclude<BoardStatus, 'done'>;
14
14
  export type WorktreeSource = 'newBranchFromMain' | 'existingBranch' | 'disabled';
15
15
  export interface Task {
16
16
  id: string;
17
+ /** @deprecated Use `TaskDetail.prompt` in the bounded Task read APIs. */
17
18
  initial_prompt: string;
18
19
  status: BoardStatus;
20
+ /** @deprecated Legacy execution override retained for API version 1 compatibility. */
19
21
  prompt: string | null;
20
22
  /** Explicit display title; null means fall back to the prompt-derived title. */
21
23
  title: string | null;
@@ -37,6 +39,53 @@ export interface Task {
37
39
  created_at: number;
38
40
  updated_at: number;
39
41
  }
42
+ export interface TaskLabel {
43
+ id: number;
44
+ projectId: string;
45
+ name: string;
46
+ }
47
+ export interface TaskReference {
48
+ id: string;
49
+ status: BoardStatus;
50
+ projectId: string;
51
+ title: string;
52
+ dependsOn: string[];
53
+ }
54
+ export interface TaskSummary extends TaskReference {
55
+ createdAt: number;
56
+ updatedAt: number;
57
+ promptPreview: string;
58
+ labels: TaskLabel[];
59
+ sourceTicketUrl: string | null;
60
+ }
61
+ export interface TaskDetail extends TaskSummary {
62
+ prompt: string;
63
+ agent: string | null;
64
+ permissionMode: string | null;
65
+ worktreeSource: WorktreeSource | null;
66
+ worktreeBranch: string | null;
67
+ titleSource: 'manual' | 'generated' | null;
68
+ titleGeneratedAt: number | null;
69
+ }
70
+ export interface ActiveTasks {
71
+ tasks: TaskDetail[];
72
+ related: TaskReference[];
73
+ }
74
+ export interface CompletedTaskQuery {
75
+ /** At most 200 Unicode characters. ASCII case-insensitive; non-ASCII casing is exact. */
76
+ search?: string;
77
+ /** At most 20 Task Label names; each trimmed name is at most 40 Unicode characters. */
78
+ labels?: string[];
79
+ cursor?: string | null;
80
+ }
81
+ export interface CompletedTaskPage {
82
+ tasks: TaskSummary[];
83
+ nextCursor: string | null;
84
+ }
85
+ export interface TaskRead {
86
+ task: TaskDetail;
87
+ related: TaskReference[];
88
+ }
40
89
  export interface GitBranchInfo {
41
90
  name: string;
42
91
  is_current: boolean;
@@ -601,7 +650,7 @@ export interface FileEntry {
601
650
  }
602
651
  /** File content with type information */
603
652
  export interface FileContent {
604
- type: 'text' | 'image' | 'binary' | 'document' | 'large-file';
653
+ type: 'text' | 'image' | 'video' | 'binary' | 'document' | 'large-file';
605
654
  content: string;
606
655
  mimeType: string | null;
607
656
  size: number;
package/dist/fileIcons.js CHANGED
@@ -35,6 +35,7 @@ const EXTENSION_ICONS = {
35
35
  svg: 'svg',
36
36
  png: 'image', jpg: 'image', jpeg: 'image', gif: 'image',
37
37
  webp: 'image', bmp: 'image', ico: 'image', avif: 'image',
38
+ mp4: 'video', m4v: 'video', webm: 'video', ogv: 'video', ogg: 'video', mov: 'video',
38
39
  pdf: 'pdf',
39
40
  zip: 'zip', tar: 'zip', gz: 'zip', tgz: 'zip', rar: 'zip', '7z': 'zip',
40
41
  lock: 'lock',
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { BrowserSurfaceCapture, BrowserSurfaceFeedbackSelection, BrowserSur
3
3
  export { OPENFORGE_PACKAGE_METADATA_SCHEMA, OPENFORGE_PLUGIN_CAPABILITIES, isOpenForgePackageMetadata, isPluginPackageMetadata, isSupportedOpenForgeApiVersion, validateOpenForgePackageMetadata, validatePluginPackageMetadata, } from './manifest';
4
4
  export { createMemoryPluginStorage, createMockBackendOpenForgeApi, createMockFrontendOpenForgeApi, createMockOpenForgeApi, createMockPluginContext, createOpenForgeRegistryFake, createTestingCalls, TestingOpenForgeRegistryFake, TestingSubscriptionSink, } from './testing';
5
5
  export { MAX_AGENT_SESSION_PAGE_SIZE, DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, TaskFollowUpError, MAX_SUPPORTED_API_VERSION, MIN_SUPPORTED_API_VERSION, OPENFORGE_PLUGIN_API_VERSION, SUPPORTED_OPENFORGE_API_VERSIONS, isPluginViewKey, resolveExternalTextFileChunkSize, makePluginViewKey, parsePluginViewKey, } from './types';
6
- export type { AgentSessionCursor, AgentSessionOverlap, AgentSessionsAPI, AgentSessionSummary, AgentSessionSummaryPage, AgentSessionTaskSummary, AgentSessionWorkspace, AgentCommandDescriptor, AgentCommandMetadata, AgentCommandRuntime, AttentionAPI, BackendReadyState, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, ConfigureStartPromptContributionRequest, CreateTaskRequest, Disposable, BackendFileSystemAPI, ExternalFileMetadata, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, ExternalReadFileSystemAPI, FileSystemAPI, UserDataDirectoryRequest, UserDataFileAppendResult, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, ImplementationRun, ListAgentSessionsRequest, ListTaskSessionsRequest, InjectionPointLocation, JsonObject, JsonPrimitive, JsonSchema, JsonValue, MaybePromise, KeyValueConfigAPI, NotificationRequest, NavigationAPI, NotificationsAPI, OpenForgeCommonAPI, OpenForgeContextChangeHandler, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, OpenForgePluginCapability, OpenForgePluginContext, OpenForgePluginPackageJson, PluginCommandInvocationContext, PluginCommandInvocationSource, PluginComponentLoader, PluginComponentModule, PluginEntry, PluginIcon, PluginSidebarNavigationProps, PluginSidebarViewIdentity, PluginInjectionPointProps, PluginSettingsSectionProps, PluginState, PluginSvgIcon, PluginTaskPaneProps, PluginTaskUISectionProps, PluginViewProps, PluginStorage, PluginStorageScope, PluginViewKey, ProjectsAPI, PtyBufferState, ShellAPI, ShellResizeRequest, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, TerminalImageProtocol, TerminalViewSnapshot, ShellSessionRequest, ShellSpawnRequest, ShellWriteRequest, StartPromptContribution, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskFollowUpDisposition, TaskFollowUpErrorCode, TaskFollowUpReceipt, SubscriptionSink, SupportedOpenForgeApiVersion, SystemAPI, TasksAPI, ValidationError, } from './types';
6
+ export type { AgentSessionCursor, AgentSessionOverlap, AgentSessionsAPI, AgentSessionSummary, AgentSessionSummaryPage, AgentSessionTaskSummary, AgentSessionWorkspace, AgentCommandDescriptor, AgentCommandMetadata, AgentCommandRuntime, AttentionAPI, BackendReadyState, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, ConfigureStartPromptContributionRequest, CreateTaskRequest, Disposable, BackendFileSystemAPI, ExternalFileMetadata, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, ExternalReadFileSystemAPI, FileSystemAPI, UserDataDirectoryRequest, UserDataFileAppendResult, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, ImplementationRun, ListAgentSessionsRequest, ListTaskSessionsRequest, InjectionPointLocation, JsonObject, JsonPrimitive, JsonSchema, JsonValue, MaybePromise, KeyValueConfigAPI, NotificationRequest, NavigationAPI, NotificationsAPI, OpenForgeCommonAPI, OpenForgeContextChangeHandler, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, OpenForgePluginCapability, OpenForgePluginContext, OpenForgePluginPackageJson, PluginCommandInvocationContext, PluginCommandInvocationSource, PluginComponentLoader, PluginComponentModule, PluginEntry, PluginIcon, PluginSidebarNavigationProps, PluginSidebarViewIdentity, PluginInjectionPointProps, PluginSettingsSectionProps, PluginState, PluginSvgIcon, PluginTaskPaneProps, PluginTaskUISectionProps, PluginViewProps, PluginStorage, PluginStorageScope, PluginViewKey, ProjectsAPI, PtyBufferState, ShellAPI, ShellResizeRequest, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, TerminalImageProtocol, TerminalViewSnapshot, ShellSessionRequest, ShellSpawnRequest, ShellWriteRequest, StartPromptContribution, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskFollowUpDisposition, TaskFollowUpErrorCode, TaskFollowUpReceipt, SubscriptionSink, SupportedOpenForgeApiVersion, SystemAPI, TaskChangeEvent, TaskChangeReason, TaskOperationsAPI, TasksAPI, ValidationError, } from './types';
7
7
  export type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingBackgroundServiceContribution, TestingBackendMethodContribution, TestingCommandContribution, TestingContributionBase, TestingExternalTextFile, TestingExternalTextFileChunksCall, TestingEventListenerContribution, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot, TestingRuntimeKind, TestingRuntimeScope, TestingSettingsSectionContribution, TestingTaskPaneTabContribution, TestingTaskUISectionContribution, TestingViewContribution, } from './testing';
8
8
  export { parseStrictFiniteNumber } from './numberParsing';
9
9
  export { buildProjectFileTree, flattenVisibleProjectFileTree, formatProjectFileTreeSize, getProjectFileTreeDepth, getProjectFileTreeItemAccessibility, getProjectFileTreeKeyboardAction, getProjectFileTreeParentPath, hasProjectFileTreeShortcutModifier, projectFileTreePathToId, } from './projectFileTree';
package/dist/markdown.js CHANGED
@@ -1,10 +1,16 @@
1
- import { marked } from 'marked';
1
+ import { marked, Renderer } from 'marked';
2
2
  import { sanitizeHtml } from './sanitize';
3
3
  /** Attribute holding the original URL of a `<img>`/`<a>` awaiting resolution. */
4
4
  export const MARKDOWN_REMOTE_MEDIA_ATTRIBUTE = 'data-markdown-remote-src';
5
+ class MarkdownRenderer extends Renderer {
6
+ table(token) {
7
+ return `<div class="markdown-table-scroll">${super.table(token)}</div>`;
8
+ }
9
+ }
5
10
  const markedOptions = {
6
11
  gfm: true,
7
12
  breaks: true,
13
+ renderer: new MarkdownRenderer(),
8
14
  };
9
15
  const RENDERED_MARKDOWN_CACHE_CAPACITY = 100;
10
16
  const renderedMarkdownCache = new Map();
@@ -0,0 +1,13 @@
1
+ export type MermaidTheme = 'default' | 'dark';
2
+ export declare const MERMAID_DIAGRAM_CLASS = "mermaid-diagram";
3
+ export declare const MERMAID_EXPAND_ACTION_CLASS = "mermaid-diagram-expand";
4
+ export declare function resolveMermaidTheme(doc?: Document): MermaidTheme;
5
+ export interface RenderedMermaidDiagram {
6
+ trigger: HTMLButtonElement;
7
+ wrapper: HTMLDivElement;
8
+ svg: string;
9
+ }
10
+ export declare function getRenderedMermaidSvg(wrapper: HTMLDivElement): string | null;
11
+ export declare function getRenderedMermaidDiagram(target: Element): RenderedMermaidDiagram | null;
12
+ export declare function renderMermaidDiagrams(root: HTMLElement, isCurrent?: () => boolean): Promise<void>;
13
+ export declare function observeMermaidTheme(doc: Document, onChange: () => void): () => void;
@@ -0,0 +1,221 @@
1
+ import { sanitizeMermaidSvg } from './sanitize';
2
+ const MERMAID_CODE_SELECTOR = 'pre > code.language-mermaid';
3
+ export const MERMAID_DIAGRAM_CLASS = 'mermaid-diagram';
4
+ export const MERMAID_EXPAND_ACTION_CLASS = 'mermaid-diagram-expand';
5
+ const MERMAID_FAILURE_CLASS = 'mermaid-diagram-fallback';
6
+ const MERMAID_EXTERNAL_RESOURCE_PATTERN = /(?:\b(?:https?|ftp|file|data|javascript|vbscript):|(?:^|[\s("'=])\/\/|@import|\burl\s*\()/im;
7
+ const MERMAID_ESCAPED_STYLE_PATTERN = /\b(?:classDef|style|linkStyle)\b[^\r\n]*\\/i;
8
+ let diagramId = 0;
9
+ let renderQueue = Promise.resolve();
10
+ const mermaidThemeObservations = new WeakMap();
11
+ function loadMermaid() {
12
+ return import('mermaid').then(module => module.default);
13
+ }
14
+ export function resolveMermaidTheme(doc = document) {
15
+ const themeName = doc.documentElement.getAttribute('data-theme');
16
+ if (themeName)
17
+ return themeName.includes('dark') ? 'dark' : 'default';
18
+ if (doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches)
19
+ return 'dark';
20
+ return 'default';
21
+ }
22
+ function queueMermaidRender(operation) {
23
+ const result = renderQueue.then(operation, operation);
24
+ renderQueue = result.then(() => undefined, () => undefined);
25
+ return result;
26
+ }
27
+ function assertSafeMermaidSource(source) {
28
+ if (MERMAID_EXTERNAL_RESOURCE_PATTERN.test(source) || MERMAID_ESCAPED_STYLE_PATTERN.test(source)) {
29
+ throw new Error('Mermaid source contains an external resource');
30
+ }
31
+ }
32
+ async function renderMermaidSvg(source, theme) {
33
+ assertSafeMermaidSource(source);
34
+ return queueMermaidRender(async () => {
35
+ const mermaid = await loadMermaid();
36
+ mermaid.initialize({
37
+ startOnLoad: false,
38
+ securityLevel: 'strict',
39
+ suppressErrorRendering: true,
40
+ maxTextSize: 50_000,
41
+ maxEdges: 500,
42
+ secure: [
43
+ 'secure',
44
+ 'securityLevel',
45
+ 'startOnLoad',
46
+ 'suppressErrorRendering',
47
+ 'maxTextSize',
48
+ 'maxEdges',
49
+ 'theme',
50
+ 'themeVariables',
51
+ 'themeCSS',
52
+ 'fontFamily',
53
+ 'altFontFamily',
54
+ 'dompurifyConfig',
55
+ 'htmlLabels',
56
+ 'flowchart',
57
+ ],
58
+ theme,
59
+ htmlLabels: false,
60
+ flowchart: { htmlLabels: false },
61
+ });
62
+ const { svg } = await mermaid.render(`openforge-mermaid-${++diagramId}`, source);
63
+ return sanitizeMermaidSvg(svg);
64
+ });
65
+ }
66
+ function prepareDiagram(code) {
67
+ const fallback = code.parentElement;
68
+ if (!(fallback instanceof HTMLPreElement))
69
+ return null;
70
+ const existingWrapper = fallback.parentElement;
71
+ if (existingWrapper instanceof HTMLDivElement && existingWrapper.classList.contains(MERMAID_DIAGRAM_CLASS)) {
72
+ return { wrapper: existingWrapper, source: code.textContent?.trim() ?? '', fallback };
73
+ }
74
+ const wrapper = document.createElement('div');
75
+ wrapper.className = MERMAID_DIAGRAM_CLASS;
76
+ wrapper.setAttribute('role', 'group');
77
+ wrapper.setAttribute('aria-label', 'Mermaid diagram');
78
+ fallback.before(wrapper);
79
+ wrapper.append(fallback);
80
+ return { wrapper, source: code.textContent?.trim() ?? '', fallback };
81
+ }
82
+ function clearRenderedDiagram(wrapper, fallback) {
83
+ const expandAction = wrapper.querySelector(`:scope > button.${MERMAID_EXPAND_ACTION_CLASS}`);
84
+ for (const child of Array.from(wrapper.children)) {
85
+ if (child !== fallback && child !== expandAction)
86
+ child.remove();
87
+ }
88
+ if (expandAction)
89
+ expandAction.hidden = true;
90
+ fallback.hidden = false;
91
+ wrapper.classList.remove('mermaid-diagram-rendered', MERMAID_FAILURE_CLASS);
92
+ }
93
+ function showRenderFailure(wrapper, fallback) {
94
+ clearRenderedDiagram(wrapper, fallback);
95
+ const message = document.createElement('p');
96
+ message.className = 'mermaid-diagram-error';
97
+ message.setAttribute('role', 'status');
98
+ message.textContent = 'Unable to render Mermaid diagram. Showing source instead.';
99
+ wrapper.prepend(message);
100
+ wrapper.classList.add(MERMAID_FAILURE_CLASS);
101
+ }
102
+ function fitSvgToRenderedContent(svg) {
103
+ if (typeof svg.getBBox !== 'function')
104
+ return;
105
+ try {
106
+ const bounds = svg.getBBox();
107
+ if (![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite))
108
+ return;
109
+ if (bounds.width <= 0 || bounds.height <= 0)
110
+ return;
111
+ const padding = 8;
112
+ const width = bounds.width + padding * 2;
113
+ const height = bounds.height + padding * 2;
114
+ svg.setAttribute('viewBox', [
115
+ bounds.x - padding,
116
+ bounds.y - padding,
117
+ width,
118
+ height,
119
+ ].join(' '));
120
+ svg.style.maxWidth = `${Math.ceil(width)}px`;
121
+ }
122
+ catch {
123
+ // Keep Mermaid's original viewport when the browser cannot measure SVG geometry.
124
+ }
125
+ }
126
+ function createDiagramExpandAction(doc) {
127
+ const button = doc.createElement('button');
128
+ button.type = 'button';
129
+ button.className = MERMAID_EXPAND_ACTION_CLASS;
130
+ button.setAttribute('aria-label', 'Expand Mermaid diagram');
131
+ button.title = 'Expand Mermaid diagram';
132
+ button.textContent = 'Expand';
133
+ return button;
134
+ }
135
+ export function getRenderedMermaidSvg(wrapper) {
136
+ if (!wrapper.isConnected || !wrapper.classList.contains('mermaid-diagram-rendered'))
137
+ return null;
138
+ return wrapper.querySelector(':scope > svg')?.outerHTML ?? null;
139
+ }
140
+ export function getRenderedMermaidDiagram(target) {
141
+ const trigger = target.closest(`button.${MERMAID_EXPAND_ACTION_CLASS}`);
142
+ if (!(trigger instanceof HTMLButtonElement))
143
+ return null;
144
+ const wrapper = trigger.closest(`.${MERMAID_DIAGRAM_CLASS}`);
145
+ if (!(wrapper instanceof HTMLDivElement))
146
+ return null;
147
+ const svg = getRenderedMermaidSvg(wrapper);
148
+ return svg ? { trigger, wrapper, svg } : null;
149
+ }
150
+ function showRenderedDiagram(wrapper, fallback, sanitizedSvg) {
151
+ const template = document.createElement('template');
152
+ template.innerHTML = sanitizedSvg;
153
+ const svg = template.content.querySelector('svg');
154
+ if (!svg)
155
+ return false;
156
+ if (!svg.hasAttribute('role'))
157
+ svg.setAttribute('role', 'img');
158
+ if (!svg.hasAttribute('aria-label') && !svg.hasAttribute('aria-labelledby')) {
159
+ svg.setAttribute('aria-label', 'Mermaid diagram');
160
+ }
161
+ clearRenderedDiagram(wrapper, fallback);
162
+ wrapper.insertBefore(template.content, fallback);
163
+ fitSvgToRenderedContent(svg);
164
+ const expandAction = wrapper.querySelector(`:scope > button.${MERMAID_EXPAND_ACTION_CLASS}`)
165
+ ?? createDiagramExpandAction(wrapper.ownerDocument);
166
+ expandAction.hidden = false;
167
+ if (!expandAction.parentElement)
168
+ wrapper.insertBefore(expandAction, fallback);
169
+ fallback.hidden = true;
170
+ wrapper.classList.add('mermaid-diagram-rendered');
171
+ return true;
172
+ }
173
+ export async function renderMermaidDiagrams(root, isCurrent = () => true) {
174
+ const diagrams = Array.from(root.querySelectorAll(MERMAID_CODE_SELECTOR))
175
+ .map(prepareDiagram)
176
+ .filter((diagram) => diagram !== null);
177
+ const theme = resolveMermaidTheme(root.ownerDocument);
178
+ await Promise.all(diagrams.map(async ({ wrapper, source, fallback }) => {
179
+ clearRenderedDiagram(wrapper, fallback);
180
+ if (!source) {
181
+ showRenderFailure(wrapper, fallback);
182
+ return;
183
+ }
184
+ try {
185
+ const svg = await renderMermaidSvg(source, theme);
186
+ if (!isCurrent() || !wrapper.isConnected)
187
+ return;
188
+ if (!showRenderedDiagram(wrapper, fallback, svg))
189
+ showRenderFailure(wrapper, fallback);
190
+ }
191
+ catch {
192
+ if (isCurrent() && wrapper.isConnected)
193
+ showRenderFailure(wrapper, fallback);
194
+ }
195
+ }));
196
+ }
197
+ export function observeMermaidTheme(doc, onChange) {
198
+ let observation = mermaidThemeObservations.get(doc);
199
+ if (!observation) {
200
+ const listeners = new Set();
201
+ const notify = () => listeners.forEach(listener => listener());
202
+ const observer = new MutationObserver((mutations) => {
203
+ if (mutations.some(mutation => mutation.attributeName === 'data-theme'))
204
+ notify();
205
+ });
206
+ observer.observe(doc.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
207
+ const media = doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)');
208
+ media?.addEventListener?.('change', notify);
209
+ observation = { listeners, observer, media, notify };
210
+ mermaidThemeObservations.set(doc, observation);
211
+ }
212
+ observation.listeners.add(onChange);
213
+ return () => {
214
+ observation.listeners.delete(onChange);
215
+ if (observation.listeners.size > 0)
216
+ return;
217
+ observation.observer.disconnect();
218
+ observation.media?.removeEventListener?.('change', observation.notify);
219
+ mermaidThemeObservations.delete(doc);
220
+ };
221
+ }
@@ -0,0 +1,23 @@
1
+ export declare const MIN_MERMAID_ZOOM = 0.25;
2
+ export declare const MAX_MERMAID_ZOOM = 4;
3
+ export declare const MERMAID_ZOOM_STEP = 0.25;
4
+ export interface MermaidSize {
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export type MermaidZoomState = {
9
+ mode: 'fit';
10
+ } | {
11
+ mode: 'manual';
12
+ scale: number;
13
+ };
14
+ export declare const FIT_MERMAID_ZOOM: MermaidZoomState;
15
+ export declare function calculateMermaidFitScale(content: MermaidSize, viewport: MermaidSize): number | null;
16
+ export declare function createManualMermaidZoom(scale: number): MermaidZoomState;
17
+ export declare function resolveMermaidZoomScale(state: MermaidZoomState, fitScale: number | null): number;
18
+ export declare function zoomMermaidIn(state: MermaidZoomState, fitScale: number | null): MermaidZoomState;
19
+ export declare function zoomMermaidOut(state: MermaidZoomState, fitScale: number | null): MermaidZoomState;
20
+ export declare function resetMermaidZoom(): MermaidZoomState;
21
+ export declare function canZoomMermaidIn(state: MermaidZoomState, fitScale: number | null): boolean;
22
+ export declare function canZoomMermaidOut(state: MermaidZoomState, fitScale: number | null): boolean;
23
+ export declare function formatMermaidZoomLabel(state: MermaidZoomState, fitScale: number | null): string;