@openforge-app/plugin-sdk 0.2.9 → 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.
Files changed (41) hide show
  1. package/dist/backend.d.ts +2 -2
  2. package/dist/collapsibleSectionState.js +30 -12
  3. package/dist/domain.d.ts +1 -0
  4. package/dist/domain.js +2 -1
  5. package/dist/frontend.d.ts +2 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +1 -1
  8. package/dist/manifest.d.ts +1 -0
  9. package/dist/manifest.js +10 -4
  10. package/dist/markdown.d.ts +9 -0
  11. package/dist/markdown.js +57 -1
  12. package/dist/mermaid.d.ts +13 -0
  13. package/dist/mermaid.js +221 -0
  14. package/dist/mermaidZoom.d.ts +23 -0
  15. package/dist/mermaidZoom.js +52 -0
  16. package/dist/openforgePackageMetadataSchema.json +2 -11
  17. package/dist/publicEntrypoints.d.mts +27 -0
  18. package/dist/publicEntrypoints.mjs +104 -0
  19. package/dist/publicUiExports.mjs +12 -22
  20. package/dist/registryValidation.d.mts +11 -0
  21. package/dist/registryValidation.mjs +30 -0
  22. package/dist/sanitize.d.ts +2 -0
  23. package/dist/sanitize.js +131 -0
  24. package/dist/taskBrowserDevToolsShortcuts.d.ts +12 -0
  25. package/dist/taskBrowserDevToolsShortcuts.js +20 -0
  26. package/dist/testing/commonApiFake.js +240 -7
  27. package/dist/testing/contracts.d.ts +15 -3
  28. package/dist/testing/frontendContributionFake.d.ts +2 -3
  29. package/dist/testing/frontendContributionFake.js +0 -32
  30. package/dist/testing/support.d.ts +5 -2
  31. package/dist/testing/support.js +12 -1
  32. package/dist/types.d.ts +91 -20
  33. package/dist/types.js +1 -1
  34. package/dist/ui/MarkdownContent.svelte +46 -0
  35. package/dist/ui/MermaidDiagramPreview.svelte +216 -0
  36. package/dist/ui/Modal.svelte +39 -4
  37. package/dist/ui/PluginPageHeader.svelte +11 -4
  38. package/dist/ui/PluginPageShell.svelte +20 -0
  39. package/dist/ui/ProjectFileTree.svelte +192 -0
  40. package/dist/vite.js +7 -17
  41. package/package.json +14 -6
@@ -0,0 +1,27 @@
1
+ export type OpenForgePluginSdkConditionalExport = Readonly<{
2
+ types: `./dist/${string}.d.ts`
3
+ default: `./dist/${string}.js`
4
+ }>
5
+
6
+ export type OpenForgePluginSdkPublicEntrypoint = Readonly<{
7
+ packageSubpath: '.' | `./${string}`
8
+ importSpecifier: '@openforge-app/plugin-sdk' | `@openforge-app/plugin-sdk/${string}`
9
+ sourcePath: `src/${string}`
10
+ workspaceSourcePath: `packages/plugin-sdk/src/${string}`
11
+ packageExport: string | OpenForgePluginSdkConditionalExport
12
+ }>
13
+
14
+ export const OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS: readonly OpenForgePluginSdkPublicEntrypoint[]
15
+
16
+ export function createOpenForgePluginSdkPackageExports(): Record<
17
+ string,
18
+ string | { types: string; default: string }
19
+ >
20
+
21
+ export function createOpenForgePluginSdkTypeScriptPaths(): Record<string, [string]>
22
+
23
+ export function loadOpenForgePluginSdkTypeScriptPaths(workspaceRoot: string): Promise<Record<string, unknown>>
24
+ export function assertOpenForgePluginSdkEntrypointRegistries(registries: {
25
+ packageExports: unknown
26
+ typeScriptPaths: unknown
27
+ }): void
@@ -0,0 +1,104 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { assertRegistryMatchesCanonicalManifest } from './registryValidation.mjs'
4
+ import { OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS } from './publicUiExports.mjs'
5
+
6
+ const PLUGIN_SDK_PACKAGE_NAME = '@openforge-app/plugin-sdk'
7
+
8
+ function moduleEntrypoint(packageSubpath, sourceName) {
9
+ const importSuffix = packageSubpath === '.' ? '' : packageSubpath.slice(1)
10
+ return Object.freeze({
11
+ packageSubpath,
12
+ importSpecifier: `${PLUGIN_SDK_PACKAGE_NAME}${importSuffix}`,
13
+ sourcePath: `src/${sourceName}.ts`,
14
+ workspaceSourcePath: `packages/plugin-sdk/src/${sourceName}.ts`,
15
+ packageExport: Object.freeze({
16
+ types: `./dist/${sourceName}.d.ts`,
17
+ default: `./dist/${sourceName}.js`,
18
+ }),
19
+ })
20
+ }
21
+
22
+ const PUBLIC_MODULE_ENTRYPOINTS = [
23
+ moduleEntrypoint('.', 'index'),
24
+ moduleEntrypoint('./frontend', 'frontend'),
25
+ moduleEntrypoint('./backend', 'backend'),
26
+ moduleEntrypoint('./testing', 'testing'),
27
+ moduleEntrypoint('./vite', 'vite'),
28
+ moduleEntrypoint('./domain', 'domain'),
29
+ moduleEntrypoint('./prStatusPresentation', 'prStatusPresentation'),
30
+ moduleEntrypoint('./markdown', 'markdown'),
31
+ moduleEntrypoint('./numberParsing', 'numberParsing'),
32
+ moduleEntrypoint('./projectFileTree', 'projectFileTree'),
33
+ moduleEntrypoint('./sanitize', 'sanitize'),
34
+ moduleEntrypoint('./pluginIcons', 'pluginIcons'),
35
+ moduleEntrypoint('./fileIcons', 'fileIcons'),
36
+ moduleEntrypoint('./collapsibleSectionState', 'collapsibleSectionState'),
37
+ moduleEntrypoint('./taskBrowserDevToolsShortcuts', 'taskBrowserDevToolsShortcuts'),
38
+ ]
39
+
40
+ const PACKAGE_METADATA_SCHEMA_ENTRYPOINT = Object.freeze({
41
+ packageSubpath: './package-metadata-schema.json',
42
+ importSpecifier: `${PLUGIN_SDK_PACKAGE_NAME}/package-metadata-schema.json`,
43
+ sourcePath: 'src/openforgePackageMetadataSchema.json',
44
+ workspaceSourcePath: 'packages/plugin-sdk/src/openforgePackageMetadataSchema.json',
45
+ packageExport: './dist/openforgePackageMetadataSchema.json',
46
+ })
47
+
48
+ const PUBLIC_UI_ENTRYPOINTS = OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS.map((entrypoint) => Object.freeze({
49
+ packageSubpath: entrypoint.packageSubpath,
50
+ importSpecifier: entrypoint.importSpecifier,
51
+ sourcePath: entrypoint.sourcePath,
52
+ workspaceSourcePath: entrypoint.workspaceSourcePath,
53
+ packageExport: entrypoint.distPath,
54
+ }))
55
+
56
+ /** Canonical registrations for every public Plugin SDK entrypoint. */
57
+ export const OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS = Object.freeze([
58
+ ...PUBLIC_MODULE_ENTRYPOINTS.slice(0, 5),
59
+ PACKAGE_METADATA_SCHEMA_ENTRYPOINT,
60
+ ...PUBLIC_MODULE_ENTRYPOINTS.slice(5),
61
+ ...PUBLIC_UI_ENTRYPOINTS,
62
+ ])
63
+
64
+ export function createOpenForgePluginSdkPackageExports() {
65
+ return Object.fromEntries(
66
+ OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS.map(({ packageSubpath, packageExport }) => [
67
+ packageSubpath,
68
+ typeof packageExport === 'string' ? packageExport : { ...packageExport },
69
+ ]),
70
+ )
71
+ }
72
+
73
+ export function createOpenForgePluginSdkTypeScriptPaths() {
74
+ return Object.fromEntries(
75
+ OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS.map(({ importSpecifier, workspaceSourcePath }) => [
76
+ importSpecifier,
77
+ [`./${workspaceSourcePath}`],
78
+ ]),
79
+ )
80
+ }
81
+
82
+ export async function loadOpenForgePluginSdkTypeScriptPaths(workspaceRoot) {
83
+ const typeScriptConfig = JSON.parse(
84
+ (await readFile(resolve(workspaceRoot, 'tsconfig.json'), 'utf8')).replace(/\/\*[\s\S]*?\*\//g, ''),
85
+ )
86
+
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
+ }
92
+
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
+ })
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([
@@ -7,9 +9,11 @@ const PUBLIC_UI_COMPONENT_NAMES = Object.freeze([
7
9
  'ResizablePanel',
8
10
  'Modal',
9
11
  'PluginPageHeader',
12
+ 'PluginPageShell',
10
13
  'PluginViewState',
11
14
  'PluginSidebarLink',
12
15
  'FileTypeIcon',
16
+ 'ProjectFileTree',
13
17
  'CollapsibleSection',
14
18
  ])
15
19
 
@@ -36,26 +40,12 @@ export function createOpenForgePluginSdkPublicUiPackageExports() {
36
40
  }
37
41
 
38
42
  export function assertOpenForgePluginSdkPublicUiPackageExports(packageExports) {
39
- if (!packageExports || typeof packageExports !== 'object' || Array.isArray(packageExports)) {
40
- throw new Error('Plugin SDK package.json must define an exports object')
41
- }
42
-
43
- const expected = createOpenForgePluginSdkPublicUiPackageExports()
44
- const actual = Object.fromEntries(
45
- Object.entries(packageExports).filter(([subpath]) => subpath.startsWith('./ui/')),
46
- )
47
- const expectedEntries = Object.entries(expected)
48
- const missingOrMismatched = expectedEntries
49
- .filter(([subpath, distPath]) => actual[subpath] !== distPath)
50
- .map(([subpath, distPath]) => `${subpath} -> ${distPath}`)
51
- const unexpected = Object.keys(actual).filter((subpath) => !(subpath in expected))
52
-
53
- if (missingOrMismatched.length === 0 && unexpected.length === 0) return
54
-
55
- const details = [
56
- missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
57
- unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
58
- ].filter(Boolean)
59
-
60
- 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
+ })
61
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
+ }
@@ -0,0 +1,12 @@
1
+ export type TaskBrowserDevToolsShortcutPlatform = 'macos' | 'other';
2
+ export type TaskBrowserDevToolsShortcut = 'toggle' | 'elements' | 'console';
3
+ export interface TaskBrowserDevToolsShortcutInput {
4
+ key: string;
5
+ keyDown: boolean;
6
+ repeat: boolean;
7
+ control: boolean;
8
+ shift: boolean;
9
+ alt: boolean;
10
+ meta: boolean;
11
+ }
12
+ export declare function classifyTaskBrowserDevToolsShortcut(platform: TaskBrowserDevToolsShortcutPlatform, input: TaskBrowserDevToolsShortcutInput): TaskBrowserDevToolsShortcut | null;
@@ -0,0 +1,20 @@
1
+ export function classifyTaskBrowserDevToolsShortcut(platform, input) {
2
+ if (!input.keyDown || input.repeat)
3
+ return null;
4
+ if (input.key === 'f12')
5
+ return 'toggle';
6
+ if (input.key === 'c') {
7
+ const elementsModified = platform === 'macos'
8
+ ? input.meta && input.shift && !input.control && !input.alt
9
+ : input.control && input.shift && !input.meta && !input.alt;
10
+ return elementsModified ? 'elements' : null;
11
+ }
12
+ const modified = platform === 'macos'
13
+ ? input.meta && input.alt && !input.control && !input.shift
14
+ : input.control && input.shift && !input.meta && !input.alt;
15
+ if (!modified)
16
+ return null;
17
+ if (input.key === 'i')
18
+ return 'toggle';
19
+ return input.key === 'j' ? 'console' : null;
20
+ }