@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.
- package/dist/mermaid.d.ts +13 -0
- package/dist/mermaid.js +221 -0
- package/dist/mermaidZoom.d.ts +23 -0
- package/dist/mermaidZoom.js +52 -0
- package/dist/publicEntrypoints.d.mts +1 -0
- package/dist/publicEntrypoints.mjs +23 -22
- package/dist/publicUiExports.mjs +10 -22
- package/dist/registryValidation.d.mts +11 -0
- package/dist/registryValidation.mjs +30 -0
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.js +131 -0
- package/dist/testing/commonApiFake.js +34 -1
- package/dist/types.d.ts +12 -2
- package/dist/ui/MarkdownContent.svelte +46 -0
- package/dist/ui/MermaidDiagramPreview.svelte +216 -0
- package/dist/ui/Modal.svelte +6 -1
- package/package.json +6 -5
|
@@ -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;
|
package/dist/mermaid.js
ADDED
|
@@ -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 {
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/publicUiExports.mjs
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
package/dist/sanitize.d.ts
CHANGED
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>
|
package/dist/ui/Modal.svelte
CHANGED
|
@@ -179,7 +179,12 @@
|
|
|
179
179
|
{#if header}
|
|
180
180
|
{@render header()}
|
|
181
181
|
{/if}
|
|
182
|
-
<button class="btn btn-ghost
|
|
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.
|
|
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.
|
|
98
|
-
"marked": "^18.0.
|
|
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.
|
|
105
|
-
"svelte": "5.56.
|
|
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",
|