@openforge-app/plugin-sdk 0.2.10 → 0.2.11

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.
@@ -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;
@@ -0,0 +1,52 @@
1
+ export const MIN_MERMAID_ZOOM = 0.25;
2
+ export const MAX_MERMAID_ZOOM = 4;
3
+ export const MERMAID_ZOOM_STEP = 0.25;
4
+ export const FIT_MERMAID_ZOOM = { mode: 'fit' };
5
+ function isPositiveSize(size) {
6
+ return Number.isFinite(size.width)
7
+ && Number.isFinite(size.height)
8
+ && size.width > 0
9
+ && size.height > 0;
10
+ }
11
+ function clampManualScale(scale) {
12
+ if (!Number.isFinite(scale))
13
+ return 1;
14
+ return Math.min(MAX_MERMAID_ZOOM, Math.max(MIN_MERMAID_ZOOM, scale));
15
+ }
16
+ function normalizeScale(scale) {
17
+ return Math.round(scale * 10_000) / 10_000;
18
+ }
19
+ export function calculateMermaidFitScale(content, viewport) {
20
+ if (!isPositiveSize(content) || !isPositiveSize(viewport))
21
+ return null;
22
+ return Math.min(viewport.width / content.width, viewport.height / content.height);
23
+ }
24
+ export function createManualMermaidZoom(scale) {
25
+ return { mode: 'manual', scale: clampManualScale(scale) };
26
+ }
27
+ export function resolveMermaidZoomScale(state, fitScale) {
28
+ if (state.mode === 'manual')
29
+ return state.scale;
30
+ return fitScale && fitScale > 0 ? fitScale : 1;
31
+ }
32
+ export function zoomMermaidIn(state, fitScale) {
33
+ const currentScale = resolveMermaidZoomScale(state, fitScale);
34
+ return createManualMermaidZoom(normalizeScale(currentScale + MERMAID_ZOOM_STEP));
35
+ }
36
+ export function zoomMermaidOut(state, fitScale) {
37
+ const currentScale = resolveMermaidZoomScale(state, fitScale);
38
+ return createManualMermaidZoom(normalizeScale(currentScale - MERMAID_ZOOM_STEP));
39
+ }
40
+ export function resetMermaidZoom() {
41
+ return createManualMermaidZoom(1);
42
+ }
43
+ export function canZoomMermaidIn(state, fitScale) {
44
+ return resolveMermaidZoomScale(state, fitScale) < MAX_MERMAID_ZOOM;
45
+ }
46
+ export function canZoomMermaidOut(state, fitScale) {
47
+ return resolveMermaidZoomScale(state, fitScale) > MIN_MERMAID_ZOOM;
48
+ }
49
+ export function formatMermaidZoomLabel(state, fitScale) {
50
+ const percentage = Math.round(resolveMermaidZoomScale(state, fitScale) * 100);
51
+ return state.mode === 'fit' ? `Fit (${percentage}%)` : `${percentage}%`;
52
+ }
@@ -20,6 +20,7 @@ export function createOpenForgePluginSdkPackageExports(): Record<
20
20
 
21
21
  export function createOpenForgePluginSdkTypeScriptPaths(): Record<string, [string]>
22
22
 
23
+ export function loadOpenForgePluginSdkTypeScriptPaths(workspaceRoot: string): Promise<Record<string, unknown>>
23
24
  export function assertOpenForgePluginSdkEntrypointRegistries(registries: {
24
25
  packageExports: unknown
25
26
  typeScriptPaths: unknown
@@ -1,4 +1,6 @@
1
- import { isDeepStrictEqual } from 'node:util'
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { assertRegistryMatchesCanonicalManifest } from './registryValidation.mjs'
2
4
  import { OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS } from './publicUiExports.mjs'
3
5
 
4
6
  const PLUGIN_SDK_PACKAGE_NAME = '@openforge-app/plugin-sdk'
@@ -77,27 +79,26 @@ export function createOpenForgePluginSdkTypeScriptPaths() {
77
79
  )
78
80
  }
79
81
 
80
- export function assertOpenForgePluginSdkEntrypointRegistries({ packageExports, typeScriptPaths }) {
81
- assertRegistryMatches('package exports', packageExports, createOpenForgePluginSdkPackageExports())
82
- assertRegistryMatches('root TypeScript paths', typeScriptPaths, createOpenForgePluginSdkTypeScriptPaths())
83
- }
84
-
85
- function assertRegistryMatches(registryName, actual, expected) {
86
- if (!actual || typeof actual !== 'object' || Array.isArray(actual)) {
87
- throw new Error(`Plugin SDK ${registryName} must be an object`)
88
- }
89
-
90
- const missingOrMismatched = Object.entries(expected)
91
- .filter(([key, value]) => !isDeepStrictEqual(actual[key], value))
92
- .map(([key]) => key)
93
- const unexpected = Object.keys(actual).filter((key) => !(key in expected))
94
-
95
- if (missingOrMismatched.length === 0 && unexpected.length === 0) return
82
+ export async function loadOpenForgePluginSdkTypeScriptPaths(workspaceRoot) {
83
+ const typeScriptConfig = JSON.parse(
84
+ (await readFile(resolve(workspaceRoot, 'tsconfig.json'), 'utf8')).replace(/\/\*[\s\S]*?\*\//g, ''),
85
+ )
96
86
 
97
- const details = [
98
- missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
99
- unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
100
- ].filter(Boolean)
87
+ return Object.fromEntries(
88
+ Object.entries(typeScriptConfig.compilerOptions.paths)
89
+ .filter(([specifier]) => specifier === PLUGIN_SDK_PACKAGE_NAME || specifier.startsWith(`${PLUGIN_SDK_PACKAGE_NAME}/`)),
90
+ )
91
+ }
101
92
 
102
- throw new Error(`Plugin SDK ${registryName} drifted from the canonical manifest (${details.join('; ')})`)
93
+ export function assertOpenForgePluginSdkEntrypointRegistries({ packageExports, typeScriptPaths }) {
94
+ assertRegistryMatchesCanonicalManifest({
95
+ registryName: 'Plugin SDK package exports',
96
+ actual: packageExports,
97
+ expected: createOpenForgePluginSdkPackageExports(),
98
+ })
99
+ assertRegistryMatchesCanonicalManifest({
100
+ registryName: 'Plugin SDK root TypeScript paths',
101
+ actual: typeScriptPaths,
102
+ expected: createOpenForgePluginSdkTypeScriptPaths(),
103
+ })
103
104
  }
@@ -1,3 +1,5 @@
1
+ import { assertRegistryMatchesCanonicalManifest } from './registryValidation.mjs'
2
+
1
3
  const PLUGIN_SDK_PACKAGE_NAME = '@openforge-app/plugin-sdk'
2
4
 
3
5
  const PUBLIC_UI_COMPONENT_NAMES = Object.freeze([
@@ -38,26 +40,12 @@ export function createOpenForgePluginSdkPublicUiPackageExports() {
38
40
  }
39
41
 
40
42
  export function assertOpenForgePluginSdkPublicUiPackageExports(packageExports) {
41
- if (!packageExports || typeof packageExports !== 'object' || Array.isArray(packageExports)) {
42
- throw new Error('Plugin SDK package.json must define an exports object')
43
- }
44
-
45
- const expected = createOpenForgePluginSdkPublicUiPackageExports()
46
- const actual = Object.fromEntries(
47
- Object.entries(packageExports).filter(([subpath]) => subpath.startsWith('./ui/')),
48
- )
49
- const expectedEntries = Object.entries(expected)
50
- const missingOrMismatched = expectedEntries
51
- .filter(([subpath, distPath]) => actual[subpath] !== distPath)
52
- .map(([subpath, distPath]) => `${subpath} -> ${distPath}`)
53
- const unexpected = Object.keys(actual).filter((subpath) => !(subpath in expected))
54
-
55
- if (missingOrMismatched.length === 0 && unexpected.length === 0) return
56
-
57
- const details = [
58
- missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
59
- unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
60
- ].filter(Boolean)
61
-
62
- throw new Error(`Plugin SDK public UI exports drifted from the canonical manifest (${details.join('; ')})`)
43
+ assertRegistryMatchesCanonicalManifest({
44
+ registryName: 'Plugin SDK public UI exports',
45
+ actual: packageExports,
46
+ expected: createOpenForgePluginSdkPublicUiPackageExports(),
47
+ invalidRegistryMessage: 'Plugin SDK package.json must define an exports object',
48
+ includeActualEntry: ([subpath]) => subpath.startsWith('./ui/'),
49
+ formatMissingOrMismatched: ([subpath, distPath]) => `${subpath} -> ${distPath}`,
50
+ })
63
51
  }
@@ -0,0 +1,11 @@
1
+ type Registry = Record<string, unknown>
2
+ type RegistryEntry = [string, unknown]
3
+
4
+ export function assertRegistryMatchesCanonicalManifest(options: {
5
+ registryName: string
6
+ actual: unknown
7
+ expected: Registry
8
+ invalidRegistryMessage?: string
9
+ includeActualEntry?: (entry: RegistryEntry) => boolean
10
+ formatMissingOrMismatched?: (entry: RegistryEntry) => string
11
+ }): void
@@ -0,0 +1,30 @@
1
+ import { isDeepStrictEqual } from 'node:util'
2
+
3
+ export function assertRegistryMatchesCanonicalManifest({
4
+ registryName,
5
+ actual,
6
+ expected,
7
+ invalidRegistryMessage = `${registryName} must be an object`,
8
+ includeActualEntry = () => true,
9
+ formatMissingOrMismatched = ([key]) => key,
10
+ }) {
11
+ if (!actual || typeof actual !== 'object' || Array.isArray(actual)) {
12
+ throw new Error(invalidRegistryMessage)
13
+ }
14
+
15
+ const comparableActual = Object.fromEntries(Object.entries(actual).filter(includeActualEntry))
16
+
17
+ const missingOrMismatched = Object.entries(expected)
18
+ .filter(([key, value]) => !isDeepStrictEqual(comparableActual[key], value))
19
+ .map(formatMissingOrMismatched)
20
+ const unexpected = Object.keys(comparableActual).filter((key) => !(key in expected))
21
+
22
+ if (missingOrMismatched.length === 0 && unexpected.length === 0) return
23
+
24
+ const details = [
25
+ missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
26
+ unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
27
+ ].filter(Boolean)
28
+
29
+ throw new Error(`${registryName} drifted from the canonical manifest (${details.join('; ')})`)
30
+ }
@@ -4,3 +4,5 @@
4
4
  * Allows safe structural/formatting HTML through.
5
5
  */
6
6
  export declare function sanitizeHtml(dirty: string): string;
7
+ /** Sanitize generated Mermaid SVG before it is inserted into the document. */
8
+ export declare function sanitizeMermaidSvg(dirty: string): string;
package/dist/sanitize.js CHANGED
@@ -11,3 +11,134 @@ export function sanitizeHtml(dirty) {
11
11
  FORBID_ATTR: ['style'],
12
12
  });
13
13
  }
14
+ const SAFE_MERMAID_STYLE_PROPERTIES = new Set([
15
+ 'color',
16
+ 'dominant-baseline',
17
+ 'fill',
18
+ 'fill-opacity',
19
+ 'font-family',
20
+ 'font-size',
21
+ 'font-style',
22
+ 'font-weight',
23
+ 'max-width',
24
+ 'opacity',
25
+ 'stroke',
26
+ 'stroke-dasharray',
27
+ 'stroke-dashoffset',
28
+ 'stroke-linecap',
29
+ 'stroke-linejoin',
30
+ 'stroke-opacity',
31
+ 'stroke-width',
32
+ 'text-align',
33
+ 'text-anchor',
34
+ 'white-space',
35
+ ]);
36
+ function getSafeMermaidStyleDeclarations(style) {
37
+ const safeDeclarations = [];
38
+ for (const property of Array.from(style)) {
39
+ if (!SAFE_MERMAID_STYLE_PROPERTIES.has(property))
40
+ continue;
41
+ const value = style.getPropertyValue(property);
42
+ if (/url\s*\(|expression\s*\(|javascript:|data:|@import|var\s*\(/i.test(value))
43
+ continue;
44
+ const priority = style.getPropertyPriority(property);
45
+ safeDeclarations.push(`${property}: ${value}${priority ? ` !${priority}` : ''}`);
46
+ }
47
+ return safeDeclarations;
48
+ }
49
+ function removeCssAtRules(css) {
50
+ let result = '';
51
+ let index = 0;
52
+ while (index < css.length) {
53
+ if (css[index] !== '@') {
54
+ result += css[index++];
55
+ continue;
56
+ }
57
+ let depth = 0;
58
+ let quote = '';
59
+ for (; index < css.length; index++) {
60
+ const character = css[index];
61
+ if (quote) {
62
+ if (character === '\\')
63
+ index++;
64
+ else if (character === quote)
65
+ quote = '';
66
+ continue;
67
+ }
68
+ if (character === '"' || character === "'") {
69
+ quote = character;
70
+ }
71
+ else if (character === '{') {
72
+ depth++;
73
+ }
74
+ else if (character === '}') {
75
+ if (--depth <= 0) {
76
+ index++;
77
+ break;
78
+ }
79
+ }
80
+ else if (character === ';' && depth === 0) {
81
+ index++;
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ function sanitizeMermaidStylesheet(css, styleParser) {
89
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
90
+ const safeRuleSource = removeCssAtRules(withoutComments);
91
+ const rules = [];
92
+ const rulePattern = /([^{}]+)\{([^{}]*)\}/g;
93
+ let consumedUntil = 0;
94
+ let match;
95
+ while ((match = rulePattern.exec(safeRuleSource)) !== null) {
96
+ if (safeRuleSource.slice(consumedUntil, match.index).trim())
97
+ return '';
98
+ consumedUntil = rulePattern.lastIndex;
99
+ const selector = match[1].trim();
100
+ if (!selector || /[{}@\\]/.test(selector))
101
+ continue;
102
+ styleParser.setAttribute('style', match[2]);
103
+ const declarations = getSafeMermaidStyleDeclarations(styleParser.style);
104
+ if (declarations.length > 0)
105
+ rules.push(`${selector} { ${declarations.join('; ')} }`);
106
+ }
107
+ if (safeRuleSource.slice(consumedUntil).trim())
108
+ return '';
109
+ return rules.join('\n');
110
+ }
111
+ function sanitizeMermaidStyles(svg) {
112
+ const template = document.createElement('template');
113
+ template.innerHTML = svg;
114
+ const styleParser = document.createElement('span');
115
+ for (const element of template.content.querySelectorAll('[style]')) {
116
+ styleParser.setAttribute('style', element.getAttribute('style') ?? '');
117
+ const safeDeclarations = getSafeMermaidStyleDeclarations(styleParser.style);
118
+ if (safeDeclarations.length > 0) {
119
+ element.setAttribute('style', safeDeclarations.join('; '));
120
+ }
121
+ else {
122
+ element.removeAttribute('style');
123
+ }
124
+ }
125
+ for (const style of template.content.querySelectorAll('style')) {
126
+ const sanitized = sanitizeMermaidStylesheet(style.textContent ?? '', styleParser);
127
+ if (sanitized) {
128
+ style.textContent = sanitized;
129
+ }
130
+ else {
131
+ style.remove();
132
+ }
133
+ }
134
+ return template.innerHTML;
135
+ }
136
+ /** Sanitize generated Mermaid SVG before it is inserted into the document. */
137
+ export function sanitizeMermaidSvg(dirty) {
138
+ const sanitized = DOMPurify.sanitize(dirty, {
139
+ USE_PROFILES: { svg: true, svgFilters: true },
140
+ FORBID_TAGS: ['script', 'foreignObject', 'iframe', 'object', 'embed', 'image', 'a'],
141
+ FORBID_ATTR: ['href', 'xlink:href', 'target'],
142
+ });
143
+ return sanitizeMermaidStyles(sanitized);
144
+ }
@@ -2,6 +2,39 @@ import { MAX_AGENT_SESSION_PAGE_SIZE, resolveExternalTextFileChunkSize } from '.
2
2
  import { assertFunction, assertTitle, commandDescriptor, createDisposable, isJsonValue, normalizeAgentCommandMetadata, } from './support.js';
3
3
  const UTF8_ENCODER = new TextEncoder();
4
4
  const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
5
+ function readTestingUserDataDir(files, directoryPath) {
6
+ const prefix = directoryPath ? `${directoryPath}/` : '';
7
+ const entries = new Map();
8
+ for (const [filePath, content] of files) {
9
+ if (!filePath.startsWith(prefix))
10
+ continue;
11
+ const childPath = filePath.slice(prefix.length);
12
+ const separatorIndex = childPath.indexOf('/');
13
+ const name = separatorIndex === -1 ? childPath : childPath.slice(0, separatorIndex);
14
+ if (!name)
15
+ continue;
16
+ entries.set(name, separatorIndex === -1
17
+ ? {
18
+ name,
19
+ path: `${prefix}${name}`,
20
+ isDir: false,
21
+ size: UTF8_ENCODER.encode(content).byteLength,
22
+ modifiedAt: null,
23
+ }
24
+ : {
25
+ name,
26
+ path: `${prefix}${name}`,
27
+ isDir: true,
28
+ size: null,
29
+ modifiedAt: null,
30
+ });
31
+ }
32
+ return [...entries.values()].sort((left, right) => {
33
+ if (left.isDir !== right.isDir)
34
+ return left.isDir ? -1 : 1;
35
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
36
+ });
37
+ }
5
38
  const TERMINAL_AGENT_SESSION_STATUSES = new Set(['completed', 'failed', 'interrupted']);
6
39
  function encodeAgentSessionCursor(payload) {
7
40
  const bytes = UTF8_ENCODER.encode(JSON.stringify(payload));
@@ -379,7 +412,7 @@ export class TestingCommonApiFake {
379
412
  userData: {
380
413
  readDir: async (request = {}) => {
381
414
  this.services.calls.fsUserDataReadDirs.push(request);
382
- return [];
415
+ return readTestingUserDataDir(this.services.userDataTextFiles, request.path);
383
416
  },
384
417
  readTextFile: async (request) => {
385
418
  this.services.calls.fsUserDataReads.push(request);
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Component } from 'svelte';
2
2
  import type { BrowserSurfacesAPI } from './browserSurfaces';
3
- import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
3
+ import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WorktreeSource, WritableBoardStatus } from './domain';
4
4
  export type SupportedOpenForgeApiVersion = 1;
5
5
  export declare const SUPPORTED_OPENFORGE_API_VERSIONS: readonly [1, ...1[]];
6
6
  export declare const OPENFORGE_PLUGIN_API_VERSION: SupportedOpenForgeApiVersion;
@@ -501,6 +501,14 @@ export interface ComposeTaskRequest {
501
501
  initialPrompt: string;
502
502
  sourceTicketUrl?: string | null;
503
503
  title?: string | null;
504
+ /** Seeds the dialog's worktree source instead of the project default. */
505
+ worktreeSource?: WorktreeSource | null;
506
+ /**
507
+ * When `worktreeSource` is `existingBranch`, seeds the branch selector.
508
+ * Pull-request head refs may be short names; the host maps them onto the
509
+ * stored selector value (`origin/<name>` when origin has the branch).
510
+ */
511
+ worktreeBranch?: string | null;
504
512
  }
505
513
  export interface ComposeTaskResult {
506
514
  task: Task;
@@ -618,7 +626,9 @@ export interface TasksAPI {
618
626
  /**
619
627
  * Opens the host's create-task dialog pre-filled, letting the user edit the
620
628
  * prompt — including anything contributed at that injection point —
621
- * before the task exists.
629
+ * before the task exists. Optional `worktreeSource` / `worktreeBranch` seed
630
+ * the environment controls the same way `title` and `sourceTicketUrl` seed
631
+ * their fields.
622
632
  * Resolves null if they dismiss it.
623
633
  */
624
634
  compose(request: ComposeTaskRequest): Promise<ComposeTaskResult | null>;
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy } from 'svelte'
3
+ import { getRenderedMermaidDiagram, getRenderedMermaidSvg, observeMermaidTheme, renderMermaidDiagrams, type RenderedMermaidDiagram } from '../mermaid'
4
+ import MermaidDiagramPreview from './MermaidDiagramPreview.svelte'
3
5
  import {
4
6
  getMarkdownRepositoryLinkSuffix,
5
7
  MARKDOWN_REMOTE_MEDIA_ATTRIBUTE,
@@ -37,7 +39,11 @@
37
39
  }: Props = $props()
38
40
 
39
41
  let root = $state<HTMLDivElement | null>(null)
42
+ let activeMermaidDiagram = $state<RenderedMermaidDiagram | null>(null)
40
43
  let imageResolutionId = 0
44
+ let mermaidRenderId = 0
45
+ let mermaidThemeRevision = $state(0)
46
+ let stopObservingMermaidTheme: (() => void) | undefined
41
47
  let html = $derived(renderMarkdownHtml(content, {
42
48
  imageBaseUrl: resolveRepositoryImage ? null : imageBaseUrl,
43
49
  markdownFilePath,
@@ -137,6 +143,31 @@
137
143
  }))
138
144
  }
139
145
 
146
+ function synchronizeActiveMermaidDiagram() {
147
+ if (!activeMermaidDiagram) return
148
+
149
+ const svg = getRenderedMermaidSvg(activeMermaidDiagram.wrapper)
150
+ activeMermaidDiagram = svg ? { ...activeMermaidDiagram, svg } : null
151
+ }
152
+
153
+ $effect(() => {
154
+ if (!root || stopObservingMermaidTheme) return
155
+ stopObservingMermaidTheme = observeMermaidTheme(root.ownerDocument, () => {
156
+ mermaidThemeRevision++
157
+ })
158
+ })
159
+
160
+ $effect(() => {
161
+ const runId = ++mermaidRenderId
162
+ void html
163
+ void mermaidThemeRevision
164
+ if (!root) return
165
+
166
+ void renderMermaidDiagrams(root, () => runId === mermaidRenderId).then(() => {
167
+ if (runId === mermaidRenderId) synchronizeActiveMermaidDiagram()
168
+ })
169
+ })
170
+
140
171
  $effect(() => {
141
172
  const runId = ++imageResolutionId
142
173
  void html
@@ -149,6 +180,9 @@
149
180
 
150
181
  onDestroy(() => {
151
182
  imageResolutionId++
183
+ mermaidRenderId++
184
+ activeMermaidDiagram = null
185
+ stopObservingMermaidTheme?.()
152
186
  })
153
187
 
154
188
  function openMarkdownLink(href: string, absoluteHref: string) {
@@ -190,6 +224,14 @@
190
224
  function handleClick(event: MouseEvent) {
191
225
  if (!(event.target instanceof Element)) return
192
226
 
227
+ const diagram = getRenderedMermaidDiagram(event.target)
228
+ if (diagram) {
229
+ event.preventDefault()
230
+ diagram.trigger.focus()
231
+ activeMermaidDiagram = diagram
232
+ return
233
+ }
234
+
193
235
  const image = findEventImage(event.target)
194
236
  if (image && onOpenImage && image.getAttribute('src')) {
195
237
  event.preventDefault()
@@ -221,3 +263,7 @@
221
263
  <div bind:this={root} role="presentation" class="markdown-body" onclick={handleClick} onkeydown={handleKeydown}>
222
264
  {@html html}
223
265
  </div>
266
+
267
+ {#if activeMermaidDiagram}
268
+ <MermaidDiagramPreview svg={activeMermaidDiagram.svg} onClose={() => { activeMermaidDiagram = null }} />
269
+ {/if}
@@ -0,0 +1,216 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte'
3
+ import {
4
+ FIT_MERMAID_ZOOM,
5
+ calculateMermaidFitScale,
6
+ canZoomMermaidIn,
7
+ canZoomMermaidOut,
8
+ formatMermaidZoomLabel,
9
+ resetMermaidZoom,
10
+ resolveMermaidZoomScale,
11
+ zoomMermaidIn,
12
+ zoomMermaidOut,
13
+ type MermaidSize,
14
+ type MermaidZoomState,
15
+ } from '../mermaidZoom'
16
+ import Modal from './Modal.svelte'
17
+
18
+ interface Props {
19
+ svg: string
20
+ onClose: () => void
21
+ }
22
+
23
+ let { svg, onClose }: Props = $props()
24
+ let viewport = $state<HTMLDivElement | null>(null)
25
+ let svgHost = $state<HTMLDivElement | null>(null)
26
+ let closeButton = $state<HTMLButtonElement | null>(null)
27
+ let viewportSize = $state<MermaidSize>({ width: 0, height: 0 })
28
+ let zoom = $state<MermaidZoomState>(FIT_MERMAID_ZOOM)
29
+ let resizeObserver: ResizeObserver | undefined
30
+
31
+ function readSvgSize(markup: string): MermaidSize | null {
32
+ const template = document.createElement('template')
33
+ template.innerHTML = markup
34
+ const element = template.content.querySelector('svg')
35
+ if (!element) return null
36
+
37
+ const viewBox = element.getAttribute('viewBox')
38
+ ?.trim()
39
+ .split(/[\s,]+/)
40
+ .map(Number)
41
+ if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
42
+ return { width: viewBox[2], height: viewBox[3] }
43
+ }
44
+
45
+ const width = Number.parseFloat(element.getAttribute('width') ?? '')
46
+ const height = Number.parseFloat(element.getAttribute('height') ?? '')
47
+ return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
48
+ ? { width, height }
49
+ : null
50
+ }
51
+
52
+ let diagramSize = $derived(readSvgSize(svg))
53
+ let fitScale = $derived(diagramSize
54
+ ? calculateMermaidFitScale(diagramSize, viewportSize)
55
+ : null)
56
+ let renderedScale = $derived(resolveMermaidZoomScale(zoom, fitScale))
57
+ let zoomLabel = $derived(formatMermaidZoomLabel(zoom, fitScale))
58
+ let renderedWidth = $derived(diagramSize ? diagramSize.width * renderedScale : null)
59
+ let renderedHeight = $derived(diagramSize ? diagramSize.height * renderedScale : null)
60
+
61
+ function updateViewportSize(width: number, height: number) {
62
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
63
+ viewportSize = { width, height }
64
+ }
65
+
66
+ $effect(() => {
67
+ if (!viewport || resizeObserver) return
68
+
69
+ const bounds = viewport.getBoundingClientRect()
70
+ updateViewportSize(bounds.width, bounds.height)
71
+ if (typeof ResizeObserver !== 'function') return
72
+
73
+ resizeObserver = new ResizeObserver((entries) => {
74
+ const entry = entries.find(candidate => candidate.target === viewport)
75
+ if (entry) updateViewportSize(entry.contentRect.width, entry.contentRect.height)
76
+ })
77
+ resizeObserver.observe(viewport)
78
+ })
79
+
80
+ $effect(() => {
81
+ void svg
82
+ if (!svgHost || renderedWidth === null || renderedHeight === null) return
83
+
84
+ const element = svgHost.querySelector('svg') as SVGSVGElement | null
85
+ if (!element) return
86
+ element.style.display = 'block'
87
+ element.style.width = `${renderedWidth}px`
88
+ element.style.height = `${renderedHeight}px`
89
+ element.style.maxWidth = 'none'
90
+ })
91
+
92
+ onDestroy(() => {
93
+ resizeObserver?.disconnect()
94
+ resizeObserver = undefined
95
+ })
96
+
97
+ function zoomIn() {
98
+ zoom = zoomMermaidIn(zoom, fitScale)
99
+ }
100
+
101
+ function zoomOut() {
102
+ zoom = zoomMermaidOut(zoom, fitScale)
103
+ }
104
+
105
+ function resetZoom() {
106
+ zoom = resetMermaidZoom()
107
+ }
108
+
109
+ function fitToWindow() {
110
+ zoom = FIT_MERMAID_ZOOM
111
+ }
112
+
113
+ function handleKeydown(event: KeyboardEvent): boolean | void {
114
+ if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) return
115
+
116
+ if (event.key === '+' || event.key === '=') {
117
+ if (canZoomMermaidIn(zoom, fitScale)) zoomIn()
118
+ } else if (event.key === '-') {
119
+ if (canZoomMermaidOut(zoom, fitScale)) zoomOut()
120
+ } else if (event.key === '0') {
121
+ resetZoom()
122
+ } else if (event.key.toLowerCase() === 'f') {
123
+ fitToWindow()
124
+ } else {
125
+ return
126
+ }
127
+
128
+ event.preventDefault()
129
+ return true
130
+ }
131
+ </script>
132
+
133
+ <Modal
134
+ {onClose}
135
+ ariaLabel="Mermaid diagram preview"
136
+ showHeader={false}
137
+ maxWidth="calc(100vw - 2rem)"
138
+ boxClass="mermaid-diagram-preview h-[calc(100vh-2rem)] !max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)]"
139
+ initialFocus={() => closeButton}
140
+ onKeydown={handleKeydown}
141
+ >
142
+ <div class="flex min-h-0 flex-1 flex-col bg-base-300/40">
143
+ <header class="mermaid-diagram-preview-toolbar flex min-h-14 shrink-0 items-center gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
144
+ <h2 class="m-0 min-w-0 flex-1 truncate text-sm font-semibold text-base-content">Mermaid diagram preview</h2>
145
+
146
+ <div class="flex items-center gap-1" role="group" aria-label="Diagram zoom controls">
147
+ <button
148
+ type="button"
149
+ class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
150
+ aria-label="Zoom out"
151
+ title="Zoom out (-)"
152
+ disabled={!canZoomMermaidOut(zoom, fitScale)}
153
+ onclick={zoomOut}
154
+ >
155
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
156
+ <circle cx="11" cy="11" r="8" />
157
+ <path d="m21 21-4.3-4.3M8 11h6" />
158
+ </svg>
159
+ </button>
160
+
161
+ <output class="min-w-20 text-center text-xs tabular-nums text-base-content/70" aria-live="polite">{zoomLabel}</output>
162
+
163
+ <button
164
+ type="button"
165
+ class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
166
+ aria-label="Zoom in"
167
+ title="Zoom in (+)"
168
+ disabled={!canZoomMermaidIn(zoom, fitScale)}
169
+ onclick={zoomIn}
170
+ >
171
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
172
+ <circle cx="11" cy="11" r="8" />
173
+ <path d="m21 21-4.3-4.3M11 8v6M8 11h6" />
174
+ </svg>
175
+ </button>
176
+
177
+ <button
178
+ type="button"
179
+ class="btn btn-ghost btn-sm h-11 min-h-11 px-3"
180
+ aria-label="Reset zoom to 100%"
181
+ title="Reset zoom to 100% (0)"
182
+ onclick={resetZoom}
183
+ >100%</button>
184
+
185
+ <button
186
+ type="button"
187
+ class="btn btn-ghost btn-sm h-11 min-h-11 px-3"
188
+ aria-label="Fit diagram to window"
189
+ aria-pressed={zoom.mode === 'fit'}
190
+ title="Fit diagram to window (F)"
191
+ onclick={fitToWindow}
192
+ >Fit</button>
193
+ </div>
194
+
195
+ <button
196
+ bind:this={closeButton}
197
+ type="button"
198
+ class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
199
+ aria-label="Close diagram preview"
200
+ onclick={onClose}
201
+ >
202
+ <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
203
+ <path d="M18 6 6 18M6 6l12 12" />
204
+ </svg>
205
+ </button>
206
+ </header>
207
+
208
+ <div bind:this={viewport} data-testid="mermaid-preview-viewport" class="mermaid-diagram-preview-viewport min-h-0 flex-1 overflow-auto p-4">
209
+ <div data-testid="mermaid-preview-canvas" class="mermaid-diagram-preview-canvas flex h-max min-h-full w-max min-w-full items-center justify-center">
210
+ <div bind:this={svgHost} class="shrink-0">
211
+ {@html svg}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </Modal>
@@ -179,7 +179,12 @@
179
179
  {#if header}
180
180
  {@render header()}
181
181
  {/if}
182
- <button class="btn btn-ghost btn-xs shrink-0" aria-label={closeLabel} onclick={handleCloseButtonClick} type="button" disabled={closeDisabled}>✕</button>
182
+ <button class="btn btn-ghost h-11 min-h-11 w-11 min-w-11 shrink-0 p-0" aria-label={closeLabel} onclick={handleCloseButtonClick} type="button" disabled={closeDisabled}>
183
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
184
+ <path d="M18 6 6 18" />
185
+ <path d="m6 6 12 12" />
186
+ </svg>
187
+ </button>
183
188
  </div>
184
189
  {/if}
185
190
  {@render children()}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openforge-app/plugin-sdk",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -94,15 +94,16 @@
94
94
  "access": "public"
95
95
  },
96
96
  "dependencies": {
97
- "dompurify": "^3.4.13",
98
- "marked": "^18.0.10"
97
+ "dompurify": "^3.4.14",
98
+ "marked": "^18.0.11",
99
+ "mermaid": "^11.17.2"
99
100
  },
100
101
  "peerDependencies": {
101
102
  "svelte": "^5.0.0"
102
103
  },
103
104
  "devDependencies": {
104
- "@types/node": "26.2.0",
105
- "svelte": "5.56.9",
105
+ "@types/node": "26.3.0",
106
+ "svelte": "5.56.10",
106
107
  "semver": "^7.8.5",
107
108
  "typescript": "^7.0.2",
108
109
  "vitest": "^4.1.11",