@openpen/module-api 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-B9dHU-LE.js +246 -0
  3. package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-B9dHU-LE.js.map +1 -0
  4. package/dist/_chunks/inject-keys-CRXCpN-M.js +196 -0
  5. package/dist/_chunks/inject-keys-CRXCpN-M.js.map +1 -0
  6. package/dist/_chunks/module-context-registry-Cn1IWYu9.js +16 -0
  7. package/dist/_chunks/module-context-registry-Cn1IWYu9.js.map +1 -0
  8. package/dist/_chunks/passthrough-DKU7JYuQ.js +6 -0
  9. package/dist/_chunks/passthrough-DKU7JYuQ.js.map +1 -0
  10. package/dist/cursors.d.ts +83 -0
  11. package/dist/define-module.d.ts +17 -0
  12. package/dist/host/color-utils.d.ts +11 -0
  13. package/{src/host/event-bus.ts → dist/host/event-bus.d.ts} +7 -5
  14. package/dist/host/host-commands.d.ts +1 -0
  15. package/dist/host/index.d.ts +24 -0
  16. package/dist/host/index.js +51 -0
  17. package/dist/host/index.js.map +1 -0
  18. package/{src/host/module-context-registry.ts → dist/host/module-context-registry.d.ts} +5 -19
  19. package/dist/host/passthrough.d.ts +2 -0
  20. package/dist/host/popup-anchor.d.ts +3 -0
  21. package/dist/host/registry.d.ts +39 -0
  22. package/dist/host/registry.js +37 -0
  23. package/dist/host/registry.js.map +1 -0
  24. package/dist/host/slot-entries.d.ts +2 -0
  25. package/dist/host/stroke-store.d.ts +6 -0
  26. package/dist/host/stroke-style.d.ts +3 -0
  27. package/dist/host/types.d.ts +176 -0
  28. package/dist/index.d.ts +28 -0
  29. package/dist/index.js +349 -0
  30. package/dist/index.js.map +1 -0
  31. package/{src/inject-keys.ts → dist/inject-keys.d.ts} +37 -66
  32. package/{src/locale.ts → dist/locale.d.ts} +3 -22
  33. package/dist/slots.d.ts +57 -0
  34. package/dist/types/contributions.d.ts +288 -0
  35. package/dist/types/control-bar-layout.d.ts +227 -0
  36. package/dist/types/module.d.ts +208 -0
  37. package/{src/types/notification.ts → dist/types/notification.d.ts} +12 -14
  38. package/dist/types/tool.d.ts +77 -0
  39. package/dist/uikit/components/AppBanner.vue.d.ts +34 -0
  40. package/dist/uikit/components/AppButton.vue.d.ts +30 -0
  41. package/dist/uikit/components/AppButtonDropdown.vue.d.ts +52 -0
  42. package/dist/uikit/components/AppDialog.vue.d.ts +53 -0
  43. package/dist/uikit/components/AppPopover.vue.d.ts +43 -0
  44. package/dist/uikit/components/AppSegmented.vue.d.ts +19 -0
  45. package/dist/uikit/components/AppSelect.vue.d.ts +26 -0
  46. package/dist/uikit/components/AppSlider.vue.d.ts +43 -0
  47. package/dist/uikit/components/AppTabs.vue.d.ts +35 -0
  48. package/dist/uikit/components/AppToggle.vue.d.ts +15 -0
  49. package/dist/uikit/components/AppTooltip.vue.d.ts +27 -0
  50. package/dist/uikit/components/DialogHost.vue.d.ts +3 -0
  51. package/{src/uikit/composables/dialog-internal-keys.ts → dist/uikit/composables/dialog-internal-keys.d.ts} +3 -5
  52. package/dist/uikit/composables/useDialog.d.ts +399 -0
  53. package/dist/uikit/composables/useDialogPluginComponent.d.ts +9 -0
  54. package/dist/uikit/index.d.ts +42 -0
  55. package/dist/uikit/index.js +898 -0
  56. package/dist/uikit/index.js.map +1 -0
  57. package/{src/uikit/internal.ts → dist/uikit/internal.d.ts} +1 -1
  58. package/dist/uikit/internal.js +157 -0
  59. package/dist/uikit/internal.js.map +1 -0
  60. package/dist/uikit/module-api.css +851 -0
  61. package/dist/uikit/primitives.d.ts +42 -0
  62. package/{src/use-module-context.ts → dist/use-module-context.d.ts} +2 -15
  63. package/{src/validation.ts → dist/validation.d.ts} +2 -6
  64. package/package.json +28 -14
  65. package/src/cursors.ts +0 -369
  66. package/src/define-module.ts +0 -55
  67. package/src/host/color-utils.ts +0 -18
  68. package/src/host/host-commands.ts +0 -9
  69. package/src/host/index.ts +0 -53
  70. package/src/host/passthrough.ts +0 -10
  71. package/src/host/popup-anchor.ts +0 -14
  72. package/src/host/slot-entries.ts +0 -9
  73. package/src/host/stroke-store.ts +0 -20
  74. package/src/host/stroke-style.ts +0 -15
  75. package/src/index.ts +0 -131
  76. package/src/slots.ts +0 -135
  77. package/src/types/contributions.ts +0 -333
  78. package/src/types/control-bar-layout.ts +0 -174
  79. package/src/types/module.ts +0 -225
  80. package/src/types/tool.ts +0 -82
  81. package/src/uikit/components/.gitkeep +0 -0
  82. package/src/uikit/components/AppBanner.vue +0 -131
  83. package/src/uikit/components/AppButton.vue +0 -135
  84. package/src/uikit/components/AppDialog.vue +0 -393
  85. package/src/uikit/components/AppPopover.vue +0 -268
  86. package/src/uikit/components/AppSegmented.vue +0 -128
  87. package/src/uikit/components/AppSelect.vue +0 -235
  88. package/src/uikit/components/AppSlider.vue +0 -204
  89. package/src/uikit/components/AppTabs.vue +0 -163
  90. package/src/uikit/components/AppToggle.vue +0 -96
  91. package/src/uikit/components/AppTooltip.vue +0 -118
  92. package/src/uikit/components/DialogHost.vue +0 -272
  93. package/src/uikit/composables/useDialog.ts +0 -141
  94. package/src/uikit/composables/useDialogPluginComponent.ts +0 -27
  95. package/src/uikit/index.ts +0 -79
  96. package/src/uikit/primitives.ts +0 -150
  97. /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 { getModuleContext } from './host/module-context-registry'
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.1.1",
3
+ "version": "1.3.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": "./src/index.ts",
21
- "types": "./src/index.ts",
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
22
23
  "exports": {
23
24
  ".": {
24
- "types": "./src/index.ts",
25
- "import": "./src/index.ts"
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
26
27
  },
27
28
  "./uikit": {
28
- "types": "./src/uikit/index.ts",
29
- "import": "./src/uikit/index.ts"
29
+ "types": "./dist/uikit/index.d.ts",
30
+ "import": "./dist/uikit/index.js"
30
31
  },
31
- "./uikit/tokens.css": "./src/uikit/tokens.css",
32
+ "./uikit/tokens.css": "./dist/uikit/tokens.css",
32
33
  "./uikit/internal": {
33
- "types": "./src/uikit/internal.ts",
34
- "import": "./src/uikit/internal.ts"
34
+ "types": "./dist/uikit/internal.d.ts",
35
+ "import": "./dist/uikit/internal.js"
35
36
  },
36
37
  "./host": {
37
- "types": "./src/host/index.ts",
38
- "import": "./src/host/index.ts"
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"
@@ -49,8 +63,8 @@
49
63
  "sdk"
50
64
  ],
51
65
  "dependencies": {
52
- "dompurify": "^3.4.5",
53
- "reka-ui": "~2.9.7"
66
+ "dompurify": "^3.4.8",
67
+ "reka-ui": "~2.9.9"
54
68
  },
55
69
  "peerDependencies": {
56
70
  "vue": "^3.5.0",
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, '&amp;')
121
- .replace(/"/g, '&quot;')
122
- .replace(/</g, '&lt;')
123
- .replace(/>/g, '&gt;')
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
- }
@@ -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
- }
@@ -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'
package/src/host/index.ts DELETED
@@ -1,53 +0,0 @@
1
- /**
2
- * @openpen/module-api/host — Host Services Sub-module
3
- *
4
- * Exposes host shared services to module components via a clean sub-path
5
- * export. Module code MUST import from here instead of using cross-layer
6
- * '../../../' relative imports into src/composables or src/services.
7
- *
8
- * Module code MUST import from `@openpen/module-api/host` instead of
9
- * using cross-layer relative imports into src/.
10
- *
11
- * Design principle: this sub-module MUST NOT leak host internal
12
- * implementation types. All types are OpenPen SDK types (from ../types/).
13
- */
14
-
15
- // ── Stroke Style ──────────────────────────────────────────────────────────────
16
- export { useStrokeStyle } from './stroke-style'
17
- export type { StrokeColor } from './stroke-style'
18
-
19
- // ── Color Utilities ───────────────────────────────────────────────────────────
20
- export {
21
- resolveColorStyle,
22
- hexToRgb,
23
- rgbToHex,
24
- hsvToRgb,
25
- rgbToHsv,
26
- hexToHsv,
27
- hsvToHex,
28
- isValidHex,
29
- } from './color-utils'
30
-
31
- // ── Stroke Store ──────────────────────────────────────────────────────────────
32
- export { getAllStrokes, removeStrokeById, pushCommand } from './stroke-store'
33
- export type { Stroke } from './stroke-store'
34
-
35
- // ── Popup Anchor ──────────────────────────────────────────────────────────────
36
- export { usePopupAnchor, calculatePopupAnchor } from './popup-anchor'
37
-
38
- // ── Passthrough Guard ─────────────────────────────────────────────────────────
39
- export { usePassthroughGuard } from './passthrough'
40
-
41
- // ── Host Commands ─────────────────────────────────────────────────────────────
42
- export { hostCommands } from './host-commands'
43
-
44
- // ── Event Bus ─────────────────────────────────────────────────────────────────
45
- export { emit, on } from './event-bus'
46
-
47
- // ── Slot Entries ──────────────────────────────────────────────────────────────
48
- export { getSlotEntries } from './slot-entries'
49
-
50
- // ── Module Context Registry ───────────────────────────────────────────────────
51
- // Used by the renderer host (module-loader.ts) to populate/clear the registry
52
- // that backs `useModuleContext`. Plugin code MUST NOT import these directly.
53
- export { setModuleContext, clearModuleContext } from './module-context-registry'
@@ -1,10 +0,0 @@
1
- /**
2
- * @openpen/module-api/host — Passthrough Guard
3
- *
4
- * Re-exports usePassthroughGuard so module components (and UIKit wrappers)
5
- * can import from @openpen/module-api/host without cross-layer imports.
6
- *
7
- * Exposed here so module code can import without cross-layer relative paths.
8
- */
9
-
10
- export { usePassthroughGuard } from '../../../../src/composables/usePassthroughGuard'