@openpen/module-api 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-DBVFUpxc.js +245 -0
- package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-DBVFUpxc.js.map +1 -0
- package/dist/_chunks/inject-keys-CRXCpN-M.js +196 -0
- package/dist/_chunks/inject-keys-CRXCpN-M.js.map +1 -0
- package/dist/_chunks/module-context-registry-Cn1IWYu9.js +16 -0
- package/dist/_chunks/module-context-registry-Cn1IWYu9.js.map +1 -0
- package/dist/_chunks/passthrough-DKU7JYuQ.js +6 -0
- package/dist/_chunks/passthrough-DKU7JYuQ.js.map +1 -0
- package/dist/cursors.d.ts +83 -0
- package/dist/define-module.d.ts +17 -0
- package/dist/host/color-utils.d.ts +11 -0
- package/{src/host/event-bus.ts → dist/host/event-bus.d.ts} +7 -5
- package/dist/host/host-commands.d.ts +1 -0
- package/dist/host/index.d.ts +24 -0
- package/dist/host/index.js +49 -0
- package/dist/host/index.js.map +1 -0
- package/{src/host/module-context-registry.ts → dist/host/module-context-registry.d.ts} +5 -19
- package/dist/host/passthrough.d.ts +2 -0
- package/dist/host/popup-anchor.d.ts +3 -0
- package/dist/host/registry.d.ts +39 -0
- package/dist/host/registry.js +37 -0
- package/dist/host/registry.js.map +1 -0
- package/dist/host/slot-entries.d.ts +2 -0
- package/dist/host/stroke-store.d.ts +5 -0
- package/dist/host/stroke-style.d.ts +3 -0
- package/dist/host/types.d.ts +164 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/{src/inject-keys.ts → dist/inject-keys.d.ts} +37 -66
- package/{src/locale.ts → dist/locale.d.ts} +3 -22
- package/dist/slots.d.ts +57 -0
- package/dist/types/contributions.d.ts +288 -0
- package/dist/types/control-bar-layout.d.ts +227 -0
- package/dist/types/module.d.ts +208 -0
- package/{src/types/notification.ts → dist/types/notification.d.ts} +12 -14
- package/dist/types/tool.d.ts +77 -0
- package/dist/uikit/components/AppBanner.vue.d.ts +34 -0
- package/dist/uikit/components/AppButton.vue.d.ts +30 -0
- package/dist/uikit/components/AppButtonDropdown.vue.d.ts +52 -0
- package/dist/uikit/components/AppDialog.vue.d.ts +53 -0
- package/dist/uikit/components/AppPopover.vue.d.ts +43 -0
- package/dist/uikit/components/AppSegmented.vue.d.ts +19 -0
- package/dist/uikit/components/AppSelect.vue.d.ts +26 -0
- package/dist/uikit/components/AppSlider.vue.d.ts +43 -0
- package/dist/uikit/components/AppTabs.vue.d.ts +35 -0
- package/dist/uikit/components/AppToggle.vue.d.ts +13 -0
- package/dist/uikit/components/AppTooltip.vue.d.ts +27 -0
- package/dist/uikit/components/DialogHost.vue.d.ts +3 -0
- package/{src/uikit/composables/dialog-internal-keys.ts → dist/uikit/composables/dialog-internal-keys.d.ts} +3 -5
- package/dist/uikit/composables/useDialog.d.ts +399 -0
- package/dist/uikit/composables/useDialogPluginComponent.d.ts +9 -0
- package/dist/uikit/index.d.ts +42 -0
- package/dist/uikit/index.js +884 -0
- package/dist/uikit/index.js.map +1 -0
- package/{src/uikit/internal.ts → dist/uikit/internal.d.ts} +1 -1
- package/dist/uikit/internal.js +157 -0
- package/dist/uikit/internal.js.map +1 -0
- package/dist/uikit/module-api.css +851 -0
- package/dist/uikit/primitives.d.ts +42 -0
- package/{src/use-module-context.ts → dist/use-module-context.d.ts} +2 -15
- package/{src/validation.ts → dist/validation.d.ts} +2 -6
- package/package.json +26 -12
- package/packages/module-api/CHANGELOG.md +0 -27
- package/src/cursors.ts +0 -369
- package/src/define-module.ts +0 -55
- package/src/host/color-utils.ts +0 -18
- package/src/host/host-commands.ts +0 -9
- package/src/host/index.ts +0 -53
- package/src/host/passthrough.ts +0 -10
- package/src/host/popup-anchor.ts +0 -14
- package/src/host/slot-entries.ts +0 -9
- package/src/host/stroke-store.ts +0 -20
- package/src/host/stroke-style.ts +0 -15
- package/src/index.ts +0 -131
- package/src/slots.ts +0 -135
- package/src/types/contributions.ts +0 -333
- package/src/types/control-bar-layout.ts +0 -174
- package/src/types/module.ts +0 -225
- package/src/types/tool.ts +0 -82
- package/src/uikit/components/.gitkeep +0 -0
- package/src/uikit/components/AppBanner.vue +0 -131
- package/src/uikit/components/AppButton.vue +0 -135
- package/src/uikit/components/AppDialog.vue +0 -393
- package/src/uikit/components/AppPopover.vue +0 -268
- package/src/uikit/components/AppSegmented.vue +0 -128
- package/src/uikit/components/AppSelect.vue +0 -235
- package/src/uikit/components/AppSlider.vue +0 -204
- package/src/uikit/components/AppTabs.vue +0 -163
- package/src/uikit/components/AppToggle.vue +0 -96
- package/src/uikit/components/AppTooltip.vue +0 -118
- package/src/uikit/components/DialogHost.vue +0 -272
- package/src/uikit/composables/useDialog.ts +0 -141
- package/src/uikit/composables/useDialogPluginComponent.ts +0 -27
- package/src/uikit/index.ts +0 -79
- package/src/uikit/primitives.ts +0 -150
- /package/{src → dist}/uikit/tokens.css +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reka UI primitive re-exports.
|
|
3
|
+
*
|
|
4
|
+
* This module exposes the raw Reka UI headless components for plugin authors
|
|
5
|
+
* who need full control over markup and styling but still want Reka UI's
|
|
6
|
+
* a11y / focus / keyboard navigation behaviour.
|
|
7
|
+
*
|
|
8
|
+
* Usage (escape hatch from high-level wrappers):
|
|
9
|
+
* import { PopoverRoot, PopoverTrigger, ... } from '@openpen/module-api/uikit'
|
|
10
|
+
* // or directly:
|
|
11
|
+
* import { ... } from '@openpen/module-api/uikit/primitives' (if re-exported)
|
|
12
|
+
*
|
|
13
|
+
* WARNING: Walking this layer means you must manually handle:
|
|
14
|
+
* - Modal manager mutual exclusion
|
|
15
|
+
* - ControlBar animation auto-close (inject CONTROL_BAR_ANIMATING_KEY)
|
|
16
|
+
* - Mouse passthrough guard (usePassthroughGuard)
|
|
17
|
+
* - Teleport target (inject WRAPPER_EL_KEY)
|
|
18
|
+
*
|
|
19
|
+
* See docs/uikit/index.md for the full trade-off description.
|
|
20
|
+
*
|
|
21
|
+
* All v1.0 high-level wrappers are implemented — prefer them over
|
|
22
|
+
* the corresponding primitive:
|
|
23
|
+
* Popover → AppPopover
|
|
24
|
+
* Dialog → AppDialog
|
|
25
|
+
* Slider → AppSlider
|
|
26
|
+
* Switch → AppToggle
|
|
27
|
+
* RadioGroup → AppSegmented
|
|
28
|
+
* Select → AppSelect
|
|
29
|
+
* Tooltip → AppTooltip
|
|
30
|
+
* Tabs → AppTabs
|
|
31
|
+
*/
|
|
32
|
+
export { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent, PopoverArrow, PopoverClose, PopoverAnchor, } from 'reka-ui';
|
|
33
|
+
export { DialogRoot, DialogTrigger, DialogPortal, DialogContent, DialogOverlay, DialogClose, DialogTitle, DialogDescription, } from 'reka-ui';
|
|
34
|
+
export { SliderRoot, SliderTrack, SliderRange, SliderThumb, } from 'reka-ui';
|
|
35
|
+
export { SwitchRoot, SwitchThumb, } from 'reka-ui';
|
|
36
|
+
export { RadioGroupRoot, RadioGroupItem, RadioGroupIndicator, } from 'reka-ui';
|
|
37
|
+
export { SelectRoot, SelectTrigger, SelectPortal, SelectContent, SelectViewport, SelectItem, SelectItemText, SelectItemIndicator, SelectLabel, SelectGroup, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, SelectArrow, SelectValue, SelectIcon, } from 'reka-ui';
|
|
38
|
+
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow, } from 'reka-ui';
|
|
39
|
+
export { TabsRoot, TabsList, TabsTrigger, TabsContent, } from 'reka-ui';
|
|
40
|
+
export { NumberFieldRoot, NumberFieldInput, NumberFieldIncrement, NumberFieldDecrement, } from 'reka-ui';
|
|
41
|
+
export { TagsInputRoot, TagsInputInput, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputClear, } from 'reka-ui';
|
|
42
|
+
export { ComboboxRoot, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxItem, ComboboxItemIndicator, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxEmpty, ComboboxArrow, ComboboxCancel, } from 'reka-ui';
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ModuleSetupContext } from './types/module'
|
|
3
|
-
|
|
1
|
+
import type { ModuleSetupContext } from './types/module';
|
|
4
2
|
/**
|
|
5
3
|
* Returns the `ModuleSetupContext` that was constructed for `moduleId` by
|
|
6
4
|
* the host runtime. Call this inside a Vue component's `<script setup>` (or
|
|
@@ -36,15 +34,4 @@ import type { ModuleSetupContext } from './types/module'
|
|
|
36
34
|
* }
|
|
37
35
|
* ```
|
|
38
36
|
*/
|
|
39
|
-
export function useModuleContext(moduleId: string): ModuleSetupContext
|
|
40
|
-
const ctx = getModuleContext(moduleId)
|
|
41
|
-
if (!ctx) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
`[module-api] useModuleContext("${moduleId}") was called but no module is currently registered under that id. ` +
|
|
44
|
-
`This usually means: (a) the moduleId string does not match the id passed to defineModule, or ` +
|
|
45
|
-
`(b) the composable was called before the module's setup() ran ` +
|
|
46
|
-
`(do not call useModuleContext at module top-level — call it inside a component's setup() or onMounted()).`
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
return ctx
|
|
50
|
-
}
|
|
37
|
+
export declare function useModuleContext(moduleId: string): ModuleSetupContext;
|
|
@@ -13,9 +13,7 @@
|
|
|
13
13
|
* `plugin.json`, filesystem paths under `~/.openpen/plugins/@scope/name/`,
|
|
14
14
|
* and registry lookups.
|
|
15
15
|
*/
|
|
16
|
-
export const MODULE_ID_RE
|
|
17
|
-
/^@([a-z0-9][a-z0-9-]{0,38})\/([a-z0-9][a-z0-9-]{0,38})$/
|
|
18
|
-
|
|
16
|
+
export declare const MODULE_ID_RE: RegExp;
|
|
19
17
|
/**
|
|
20
18
|
* Returns `true` when `id` is a non-empty string that satisfies
|
|
21
19
|
* {@link MODULE_ID_RE}; acts as a type guard narrowing `unknown` → `string`.
|
|
@@ -26,6 +24,4 @@ export const MODULE_ID_RE =
|
|
|
26
24
|
* isValidModuleId('freehand') // false — must be scoped @scope/name
|
|
27
25
|
* isValidModuleId('') // false — empty string rejected
|
|
28
26
|
*/
|
|
29
|
-
export function isValidModuleId(id: unknown): id is string
|
|
30
|
-
return typeof id === 'string' && id.length > 0 && MODULE_ID_RE.test(id)
|
|
31
|
-
}
|
|
27
|
+
export declare function isValidModuleId(id: unknown): id is string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpen/module-api",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "OpenPen Module API — public contract for built-in modules and third-party plugins",
|
|
5
5
|
"author": "navishachiku <navishachiku@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/openpen-platform/openpen#readme",
|
|
@@ -17,27 +17,41 @@
|
|
|
17
17
|
},
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"type": "module",
|
|
20
|
-
"main": "./
|
|
21
|
-
"
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
22
23
|
"exports": {
|
|
23
24
|
".": {
|
|
24
|
-
"types": "./
|
|
25
|
-
"import": "./
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
26
27
|
},
|
|
27
28
|
"./uikit": {
|
|
28
|
-
"types": "./
|
|
29
|
-
"import": "./
|
|
29
|
+
"types": "./dist/uikit/index.d.ts",
|
|
30
|
+
"import": "./dist/uikit/index.js"
|
|
30
31
|
},
|
|
31
|
-
"./uikit/tokens.css": "./
|
|
32
|
+
"./uikit/tokens.css": "./dist/uikit/tokens.css",
|
|
32
33
|
"./uikit/internal": {
|
|
33
|
-
"types": "./
|
|
34
|
-
"import": "./
|
|
34
|
+
"types": "./dist/uikit/internal.d.ts",
|
|
35
|
+
"import": "./dist/uikit/internal.js"
|
|
35
36
|
},
|
|
36
37
|
"./host": {
|
|
37
|
-
"types": "./
|
|
38
|
-
"import": "./
|
|
38
|
+
"types": "./dist/host/index.d.ts",
|
|
39
|
+
"import": "./dist/host/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./host/registry": {
|
|
42
|
+
"types": "./dist/host/registry.d.ts",
|
|
43
|
+
"import": "./dist/host/registry.js"
|
|
39
44
|
}
|
|
40
45
|
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"CHANGELOG.md",
|
|
49
|
+
"README.md"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "vite build && vue-tsc -p tsconfig.build.json && node scripts/copy-static.mjs",
|
|
53
|
+
"prepublishOnly": "npm run build"
|
|
54
|
+
},
|
|
41
55
|
"engines": {
|
|
42
56
|
"node": ">=20",
|
|
43
57
|
"npm": ">=9"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## [1.1.0](https://github.com/openpen-platform/openpen/compare/v1.0.0...v1.1.0) (2026-05-19)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
### Features
|
|
7
|
-
|
|
8
|
-
* **cursors:** DOM overlay cursor system + plugin SDK doc gaps ([2f77192](https://github.com/openpen-platform/openpen/commit/2f771927fb94586b0ceef58e07fcb5220ed40c02))
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
### Bug Fixes
|
|
12
|
-
|
|
13
|
-
* **control-bar:** pause auto-collapse timer while any dialog is open ([#1](https://github.com/openpen-platform/openpen/issues/1)) ([add84ff](https://github.com/openpen-platform/openpen/commit/add84ff89f1ad5135c7371fd7d4bfd64b1ef2395))
|
|
14
|
-
|
|
15
|
-
## 1.0.0 (2026-05-14)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
### Features
|
|
19
|
-
|
|
20
|
-
* initial open source release ([f58abd7](https://github.com/openpen-platform/openpen/commit/f58abd7c4f3e12be9c88cdad23c61c82f9db7a7f))
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
### Bug Fixes
|
|
24
|
-
|
|
25
|
-
* **settings:** persist single Color Mode change to disk ([db9e6dd](https://github.com/openpen-platform/openpen/commit/db9e6dda0b9e584f349fb31da4349bdfaec0a84a))
|
|
26
|
-
|
|
27
|
-
## Changelog
|
package/src/cursors.ts
DELETED
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cursor compilation + sanitisation helpers shared by host runtime and
|
|
3
|
-
* plugin SDK.
|
|
4
|
-
*
|
|
5
|
-
* Three closure layers gate every cursor entry before it reaches
|
|
6
|
-
* `v-html`: R1–R12 shape validation here, DOMPurify content sanitisation
|
|
7
|
-
* inside compileCursor, and a deep-clone + freeze in the contribution
|
|
8
|
-
* store. Removing any one of them re-opens an attack surface the others
|
|
9
|
-
* cannot close.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import DOMPurify from 'dompurify'
|
|
13
|
-
import type {
|
|
14
|
-
CursorContribution,
|
|
15
|
-
CursorSpec,
|
|
16
|
-
Hotspot,
|
|
17
|
-
PngCursorSpec,
|
|
18
|
-
SvgCursorSpec,
|
|
19
|
-
} from './types/contributions'
|
|
20
|
-
|
|
21
|
-
export interface CompiledCursor {
|
|
22
|
-
/** Sanitised SVG / HTML fragment to mount as `innerHTML` on the cursor element. */
|
|
23
|
-
svgMarkup: string
|
|
24
|
-
/** Hotspot offset, in CSS pixels relative to the markup's top-left. */
|
|
25
|
-
hotspot: Hotspot
|
|
26
|
-
/** CSS keyword fallback retained for legacy callers. */
|
|
27
|
-
fallbackCssKeyword: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface CursorResolutionContext {
|
|
31
|
-
/**
|
|
32
|
-
* Hostname-encoded plugin id (`@acme/x` → `acme.x`) used to assemble
|
|
33
|
-
* `openpen-plugin://<hostname>/<path>` URLs for relative-path specs.
|
|
34
|
-
* Undefined for built-in modules — path-form specs without a hostname
|
|
35
|
-
* resolve to `null` (host falls back to its default cursor).
|
|
36
|
-
*/
|
|
37
|
-
pluginHostname?: string
|
|
38
|
-
/** Injection point for tests; defaults to global `fetch`. */
|
|
39
|
-
fetchImpl?: typeof fetch
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* W3C CSS UI Level 3 cursor keywords. The legacy `cursor: string` form
|
|
44
|
-
* MUST match one of these (case-insensitive after trim); everything else
|
|
45
|
-
* is rejected by R2 / R12.
|
|
46
|
-
*/
|
|
47
|
-
export const SAFE_CURSOR_KEYWORDS: ReadonlySet<string> = new Set([
|
|
48
|
-
'auto', 'default', 'none',
|
|
49
|
-
'context-menu', 'help', 'pointer', 'progress', 'wait',
|
|
50
|
-
'cell', 'crosshair', 'text', 'vertical-text',
|
|
51
|
-
'alias', 'copy', 'move', 'no-drop', 'not-allowed', 'grab', 'grabbing',
|
|
52
|
-
'e-resize', 'n-resize', 'ne-resize', 'nw-resize',
|
|
53
|
-
's-resize', 'se-resize', 'sw-resize', 'w-resize',
|
|
54
|
-
'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize',
|
|
55
|
-
'col-resize', 'row-resize', 'all-scroll',
|
|
56
|
-
'zoom-in', 'zoom-out',
|
|
57
|
-
])
|
|
58
|
-
|
|
59
|
-
const FORBIDDEN_CSS_LOAD_PATTERN = /url\s*\(|image-set\s*\(|-webkit-image-set\s*\(/i
|
|
60
|
-
|
|
61
|
-
const DEFAULT_FALLBACK_KEYWORD = 'crosshair'
|
|
62
|
-
|
|
63
|
-
export function isSafeRelativePath(p: unknown): p is string {
|
|
64
|
-
if (typeof p !== 'string' || p.length === 0) return false
|
|
65
|
-
// Bare `:` catches scheme prefixes without `://` (e.g. `data:`).
|
|
66
|
-
if (p.includes(':')) return false
|
|
67
|
-
if (p.startsWith('/')) return false
|
|
68
|
-
for (const seg of p.split('/')) {
|
|
69
|
-
if (seg === '' || seg === '..') return false
|
|
70
|
-
}
|
|
71
|
-
return true
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function pluginHostname(moduleId: string): string {
|
|
75
|
-
return moduleId.replace(/^@/, '').replace(/\//g, '.')
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function pluginAssetUrl(moduleId: string, relativePath: string): string {
|
|
79
|
-
if (!isSafeRelativePath(relativePath)) {
|
|
80
|
-
throw new Error(`Unsafe asset path: ${relativePath}`)
|
|
81
|
-
}
|
|
82
|
-
return `openpen-plugin://${pluginHostname(moduleId)}/${relativePath}`
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Sanitise SVG markup before it reaches `v-html`. Rationale for each
|
|
87
|
-
* flag: `USE_PROFILES: { svg: true }` covers script tags, on* handlers,
|
|
88
|
-
* and javascript: hrefs; `FORBID_TAGS: ['foreignObject']` closes the
|
|
89
|
-
* mXSS escape hatch the SVG profile alone does not; turning off
|
|
90
|
-
* `ALLOW_UNKNOWN_PROTOCOLS` blocks `<use href="data:...">` and friends.
|
|
91
|
-
*/
|
|
92
|
-
export function sanitizeSvgMarkup(raw: string): string {
|
|
93
|
-
return DOMPurify.sanitize(raw, {
|
|
94
|
-
USE_PROFILES: { svg: true },
|
|
95
|
-
FORBID_TAGS: ['foreignObject'],
|
|
96
|
-
// Cursor SVGs use presentation attributes (fill/stroke/etc.), never
|
|
97
|
-
// inline `style`. Forbidding it strips legacy `expression()` CSS
|
|
98
|
-
// smuggling vectors (S7) without touching anything we draw.
|
|
99
|
-
FORBID_ATTR: ['style'],
|
|
100
|
-
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
101
|
-
})
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function resolveHotspot(spec: SvgCursorSpec | PngCursorSpec): Hotspot {
|
|
105
|
-
const h = spec.hotspot
|
|
106
|
-
if (!h || typeof h !== 'object') return { x: 0, y: 0 }
|
|
107
|
-
const x = typeof h.x === 'number' && Number.isFinite(h.x) ? h.x : 0
|
|
108
|
-
const y = typeof h.y === 'number' && Number.isFinite(h.y) ? h.y : 0
|
|
109
|
-
return { x, y }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function resolveFallback(spec: SvgCursorSpec | PngCursorSpec): string {
|
|
113
|
-
return typeof spec.fallback === 'string' && spec.fallback.length > 0
|
|
114
|
-
? spec.fallback
|
|
115
|
-
: DEFAULT_FALLBACK_KEYWORD
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function escapeHtmlAttr(s: string): string {
|
|
119
|
-
return s
|
|
120
|
-
.replace(/&/g, '&')
|
|
121
|
-
.replace(/"/g, '"')
|
|
122
|
-
.replace(/</g, '<')
|
|
123
|
-
.replace(/>/g, '>')
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Convert a frozen, shape-validated CursorContribution.cursor into render
|
|
128
|
-
* data ready for `v-html`. Returns `null` for legacy keyword strings
|
|
129
|
-
* (caller falls back to the host default cursor) and for any spec the
|
|
130
|
-
* sanitiser empties out completely.
|
|
131
|
-
*
|
|
132
|
-
* Async because path-form SVG specs are fetched through `openpen-plugin://`
|
|
133
|
-
* at compile time; inline + PNG branches resolve synchronously inside the
|
|
134
|
-
* returned Promise.
|
|
135
|
-
*/
|
|
136
|
-
export async function compileCursor(
|
|
137
|
-
spec: CursorSpec,
|
|
138
|
-
context: CursorResolutionContext = {},
|
|
139
|
-
): Promise<CompiledCursor | null> {
|
|
140
|
-
if (typeof spec === 'string') return null
|
|
141
|
-
if (!spec || typeof spec !== 'object') return null
|
|
142
|
-
|
|
143
|
-
if ('svg' in spec && typeof spec.svg === 'string') {
|
|
144
|
-
const hotspot = resolveHotspot(spec)
|
|
145
|
-
const fallbackCssKeyword = resolveFallback(spec)
|
|
146
|
-
const trimmed = spec.svg.trim()
|
|
147
|
-
|
|
148
|
-
if (trimmed.startsWith('<')) {
|
|
149
|
-
const sanitised = sanitizeSvgMarkup(trimmed)
|
|
150
|
-
if (!sanitised) return null
|
|
151
|
-
return { svgMarkup: sanitised, hotspot, fallbackCssKeyword }
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (!context.pluginHostname || !isSafeRelativePath(trimmed)) return null
|
|
155
|
-
const url = `openpen-plugin://${context.pluginHostname}/${trimmed}`
|
|
156
|
-
const fetchImpl = context.fetchImpl
|
|
157
|
-
?? (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : undefined)
|
|
158
|
-
if (!fetchImpl) return null
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
const resp = await fetchImpl(url)
|
|
162
|
-
if (!resp.ok) return null
|
|
163
|
-
const raw = await resp.text()
|
|
164
|
-
const sanitised = sanitizeSvgMarkup(raw)
|
|
165
|
-
if (!sanitised) return null
|
|
166
|
-
return { svgMarkup: sanitised, hotspot, fallbackCssKeyword }
|
|
167
|
-
} catch {
|
|
168
|
-
return null
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if ('png' in spec && typeof spec.png === 'string') {
|
|
173
|
-
const hotspot = resolveHotspot(spec)
|
|
174
|
-
const fallbackCssKeyword = resolveFallback(spec)
|
|
175
|
-
const trimmed = spec.png.trim()
|
|
176
|
-
if (!context.pluginHostname || !isSafeRelativePath(trimmed)) return null
|
|
177
|
-
const url = escapeHtmlAttr(`openpen-plugin://${context.pluginHostname}/${trimmed}`)
|
|
178
|
-
const svgMarkup =
|
|
179
|
-
`<img src="${url}" alt="" style="display:block;max-width:64px;max-height:64px;" />`
|
|
180
|
-
return { svgMarkup, hotspot, fallbackCssKeyword }
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return null
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export type CursorSanitizeRule =
|
|
187
|
-
| 'R1' | 'R2' | 'R3' | 'R4' | 'R5' | 'R6'
|
|
188
|
-
| 'R7' | 'R8' | 'R9' | 'R10' | 'R11' | 'R12'
|
|
189
|
-
|
|
190
|
-
export interface CursorSanitizeDiagnostic {
|
|
191
|
-
/** Contribution id, or `'<unknown>'` if `id` was missing / invalid. */
|
|
192
|
-
id: string
|
|
193
|
-
rule: CursorSanitizeRule
|
|
194
|
-
reason: string
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export interface CursorSanitizeOptions {
|
|
198
|
-
/**
|
|
199
|
-
* Built-in modules (host bundle) cannot use path forms because they
|
|
200
|
-
* have no `openpen-plugin://<hostname>` namespace. R7 / R10 reject
|
|
201
|
-
* such contributions for built-ins.
|
|
202
|
-
*/
|
|
203
|
-
isBuiltin?: boolean
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export interface CursorSanitizeResult {
|
|
207
|
-
cleaned: CursorContribution[]
|
|
208
|
-
diagnostics: CursorSanitizeDiagnostic[]
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Shape-validate raw cursor contributions before they reach the
|
|
213
|
-
* contribution-store. This is the R1–R12 gate; SVG markup content is
|
|
214
|
-
* sanitised later inside {@link compileCursor} via DOMPurify.
|
|
215
|
-
*/
|
|
216
|
-
export function sanitizeCursorContributions(
|
|
217
|
-
cursors: unknown,
|
|
218
|
-
options: CursorSanitizeOptions = {},
|
|
219
|
-
): CursorSanitizeResult {
|
|
220
|
-
const cleaned: CursorContribution[] = []
|
|
221
|
-
const diagnostics: CursorSanitizeDiagnostic[] = []
|
|
222
|
-
|
|
223
|
-
if (!Array.isArray(cursors)) return { cleaned, diagnostics }
|
|
224
|
-
|
|
225
|
-
for (const raw of cursors) {
|
|
226
|
-
if (raw === null || typeof raw !== 'object') {
|
|
227
|
-
diagnostics.push({ id: '<unknown>', rule: 'R1', reason: 'contribution is not an object' })
|
|
228
|
-
continue
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const entry = raw as { id?: unknown; cursor?: unknown }
|
|
232
|
-
const id = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : '<unknown>'
|
|
233
|
-
|
|
234
|
-
if (id === '<unknown>') {
|
|
235
|
-
diagnostics.push({ id, rule: 'R1', reason: 'missing or empty `id`' })
|
|
236
|
-
continue
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const { cursor } = entry
|
|
240
|
-
|
|
241
|
-
if (typeof cursor === 'string') {
|
|
242
|
-
const trimmedLower = cursor.trim().toLowerCase()
|
|
243
|
-
if (FORBIDDEN_CSS_LOAD_PATTERN.test(trimmedLower)) {
|
|
244
|
-
diagnostics.push({
|
|
245
|
-
id,
|
|
246
|
-
rule: 'R12',
|
|
247
|
-
reason: 'string contains url() / image-set() / -webkit-image-set(); loads external resource',
|
|
248
|
-
})
|
|
249
|
-
continue
|
|
250
|
-
}
|
|
251
|
-
if (!SAFE_CURSOR_KEYWORDS.has(trimmedLower)) {
|
|
252
|
-
diagnostics.push({
|
|
253
|
-
id,
|
|
254
|
-
rule: 'R2',
|
|
255
|
-
reason: `string '${trimmedLower}' is not in the safe CSS cursor keyword whitelist`,
|
|
256
|
-
})
|
|
257
|
-
continue
|
|
258
|
-
}
|
|
259
|
-
cleaned.push({ id, cursor: trimmedLower })
|
|
260
|
-
continue
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
|
|
264
|
-
diagnostics.push({ id, rule: 'R1', reason: 'cursor field is neither string nor plain object' })
|
|
265
|
-
continue
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const cursorObj = cursor as Record<string, unknown>
|
|
269
|
-
const hasSvg = Object.prototype.hasOwnProperty.call(cursorObj, 'svg')
|
|
270
|
-
const hasPng = Object.prototype.hasOwnProperty.call(cursorObj, 'png')
|
|
271
|
-
|
|
272
|
-
if (!hasSvg && !hasPng) {
|
|
273
|
-
diagnostics.push({ id, rule: 'R3', reason: 'cursor object has neither `svg` nor `png` key' })
|
|
274
|
-
continue
|
|
275
|
-
}
|
|
276
|
-
if (hasSvg && hasPng) {
|
|
277
|
-
diagnostics.push({ id, rule: 'R4', reason: 'cursor object has both `svg` and `png` keys (ambiguous)' })
|
|
278
|
-
continue
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (hasSvg) {
|
|
282
|
-
const svg = cursorObj.svg
|
|
283
|
-
if (typeof svg !== 'string') {
|
|
284
|
-
diagnostics.push({ id, rule: 'R5', reason: '`svg` value is not a string' })
|
|
285
|
-
continue
|
|
286
|
-
}
|
|
287
|
-
const trimmed = svg.trim()
|
|
288
|
-
const isInline = trimmed.startsWith('<')
|
|
289
|
-
if (!isInline) {
|
|
290
|
-
if (!isSafeRelativePath(trimmed)) {
|
|
291
|
-
diagnostics.push({
|
|
292
|
-
id,
|
|
293
|
-
rule: 'R6',
|
|
294
|
-
reason: '`svg` value is non-inline and not a safe relative path',
|
|
295
|
-
})
|
|
296
|
-
continue
|
|
297
|
-
}
|
|
298
|
-
if (options.isBuiltin) {
|
|
299
|
-
diagnostics.push({
|
|
300
|
-
id,
|
|
301
|
-
rule: 'R7',
|
|
302
|
-
reason: 'built-in modules cannot use path-form `svg` (no openpen-plugin hostname)',
|
|
303
|
-
})
|
|
304
|
-
continue
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (hasPng) {
|
|
310
|
-
const png = cursorObj.png
|
|
311
|
-
if (typeof png !== 'string') {
|
|
312
|
-
diagnostics.push({ id, rule: 'R8', reason: '`png` value is not a string' })
|
|
313
|
-
continue
|
|
314
|
-
}
|
|
315
|
-
if (!isSafeRelativePath(png.trim())) {
|
|
316
|
-
diagnostics.push({
|
|
317
|
-
id,
|
|
318
|
-
rule: 'R9',
|
|
319
|
-
reason: '`png` value is not a safe relative path',
|
|
320
|
-
})
|
|
321
|
-
continue
|
|
322
|
-
}
|
|
323
|
-
if (options.isBuiltin) {
|
|
324
|
-
diagnostics.push({
|
|
325
|
-
id,
|
|
326
|
-
rule: 'R10',
|
|
327
|
-
reason: 'built-in modules cannot use `png` (no openpen-plugin hostname)',
|
|
328
|
-
})
|
|
329
|
-
continue
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (cursorObj.hotspot !== undefined) {
|
|
334
|
-
const h = cursorObj.hotspot as Record<string, unknown> | null
|
|
335
|
-
const valid =
|
|
336
|
-
h !== null &&
|
|
337
|
-
typeof h === 'object' &&
|
|
338
|
-
typeof h.x === 'number' && Number.isFinite(h.x) && h.x >= 0 &&
|
|
339
|
-
typeof h.y === 'number' && Number.isFinite(h.y) && h.y >= 0
|
|
340
|
-
if (!valid) {
|
|
341
|
-
diagnostics.push({
|
|
342
|
-
id,
|
|
343
|
-
rule: 'R11',
|
|
344
|
-
reason: '`hotspot` must be `{ x: number ≥ 0, y: number ≥ 0 }`',
|
|
345
|
-
})
|
|
346
|
-
continue
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Output a strictly-allowlisted clone — only known primitive fields
|
|
351
|
-
// pass through. Anything else (function / symbol / getter / circular
|
|
352
|
-
// reference attached as an extra key) is dropped here so the
|
|
353
|
-
// downstream `structuredClone` in the contribution-store cannot
|
|
354
|
-
// throw and trigger a module-wide rollback.
|
|
355
|
-
const cleanedCursor: SvgCursorSpec | PngCursorSpec = hasSvg
|
|
356
|
-
? { svg: (cursorObj.svg as string).trim() }
|
|
357
|
-
: { png: (cursorObj.png as string).trim() }
|
|
358
|
-
if (cursorObj.hotspot !== undefined) {
|
|
359
|
-
const h = cursorObj.hotspot as { x: number; y: number }
|
|
360
|
-
cleanedCursor.hotspot = { x: h.x, y: h.y }
|
|
361
|
-
}
|
|
362
|
-
if (typeof cursorObj.fallback === 'string') {
|
|
363
|
-
cleanedCursor.fallback = cursorObj.fallback
|
|
364
|
-
}
|
|
365
|
-
cleaned.push({ id, cursor: cleanedCursor })
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
return { cleaned, diagnostics }
|
|
369
|
-
}
|
package/src/define-module.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { isValidModuleId } from './validation'
|
|
2
|
-
import { CONTRIBUTION_KEY_TO_SLOT_ID } from './slots'
|
|
3
|
-
import type { OpenPenModule } from './types/module'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Identity helper for declaring an OpenPen module with full type
|
|
7
|
-
* inference and runtime sanity checks. Throws `Error` (preflight) when:
|
|
8
|
-
*
|
|
9
|
-
* - `id` is missing or has invalid characters
|
|
10
|
-
* - `contributes` is missing entirely
|
|
11
|
-
* - `contributes` has no contribution fields
|
|
12
|
-
* - `contributes` references a slot key not in the catalogue
|
|
13
|
-
*
|
|
14
|
-
* This is intentionally a small surface — full validation
|
|
15
|
-
* (id collisions across modules, slot quota, settingsSchema parse, etc.)
|
|
16
|
-
* happens later in the host app's `module-validator`. This helper
|
|
17
|
-
* catches the obvious developer mistakes at the module's own boundary
|
|
18
|
-
* so errors surface in the module's repo, not deep inside OpenPen.
|
|
19
|
-
*/
|
|
20
|
-
export function defineModule<TModule extends OpenPenModule>(
|
|
21
|
-
spec: TModule
|
|
22
|
-
): TModule {
|
|
23
|
-
if (!isValidModuleId(spec.id)) {
|
|
24
|
-
throw new Error(
|
|
25
|
-
`[@openpen/module-api] Invalid module id: ${JSON.stringify(spec.id)}. ` +
|
|
26
|
-
`Module ids must use \`@scope/name\` format with lowercase ASCII alphanumeric + hyphen, ` +
|
|
27
|
-
`max 39 chars per segment (e.g. \`@alice/my-plugin\`).`
|
|
28
|
-
)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (!spec.contributes || typeof spec.contributes !== 'object') {
|
|
32
|
-
throw new Error(
|
|
33
|
-
`[@openpen/module-api] Module "${spec.id}" must declare a non-empty 'contributes' object.`
|
|
34
|
-
)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const keys = Object.keys(spec.contributes)
|
|
38
|
-
if (keys.length === 0) {
|
|
39
|
-
throw new Error(
|
|
40
|
-
`[@openpen/module-api] Module "${spec.id}" must contribute to at least one slot. ` +
|
|
41
|
-
`Add at least one field to 'contributes' (e.g. 'tools', 'settingsTabs', 'shortcuts').`
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
for (const key of keys) {
|
|
46
|
-
if (!(key in CONTRIBUTION_KEY_TO_SLOT_ID)) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`[@openpen/module-api] Module "${spec.id}" contributes to unknown slot key '${key}'. ` +
|
|
49
|
-
`Known keys: ${Object.keys(CONTRIBUTION_KEY_TO_SLOT_ID).sort().join(', ')}.`
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return spec
|
|
55
|
-
}
|
package/src/host/color-utils.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @openpen/module-api/host — Color Utilities
|
|
3
|
-
*
|
|
4
|
-
* Re-exports color helpers that module components need for rendering.
|
|
5
|
-
* The canvas-rendering helper resolveColorStyle and the color manipulation
|
|
6
|
-
* utilities are exposed here so modules don't need cross-layer imports.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
resolveColorStyle,
|
|
11
|
-
hexToRgb,
|
|
12
|
-
rgbToHex,
|
|
13
|
-
hsvToRgb,
|
|
14
|
-
rgbToHsv,
|
|
15
|
-
hexToHsv,
|
|
16
|
-
hsvToHex,
|
|
17
|
-
isValidHex,
|
|
18
|
-
} from '../../../../src/services/color-utils'
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @openpen/module-api/host — Host Commands
|
|
3
|
-
*
|
|
4
|
-
* Re-exports hostCommands for module components that need to trigger
|
|
5
|
-
* host infrastructure actions (pin toggle, canvas clear, undo/redo,
|
|
6
|
-
* drawing mode toggle).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export { hostCommands } from '../../../../src/services/host-commands'
|