@moloodev/editor-sdk 0.0.0-stage → 0.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/package.json +62 -3
- package/src/channel.ts +287 -0
- package/src/chrome-manifest.ts +111 -0
- package/src/commands/clipboard/router.ts +99 -0
- package/src/commands/clipboard/types.ts +20 -0
- package/src/commands/history/stack.ts +53 -0
- package/src/commands/icons/catalog.ts +172 -0
- package/src/commands/index.ts +100 -0
- package/src/commands/keys/display.ts +16 -0
- package/src/commands/keys/normalize.ts +55 -0
- package/src/commands/keys/reserved.ts +62 -0
- package/src/commands/keys/validate.ts +77 -0
- package/src/commands/manifest/configuration.ts +59 -0
- package/src/commands/manifest/contribution-manifest-schema.ts +56 -0
- package/src/commands/manifest/editor-document.ts +501 -0
- package/src/commands/manifest/ids.ts +2 -0
- package/src/commands/manifest/merge.ts +215 -0
- package/src/commands/manifest/types.ts +113 -0
- package/src/commands/manifest/validate-manifest.ts +45 -0
- package/src/commands/react/context.tsx +249 -0
- package/src/commands/react/hotkey-defaults.ts +11 -0
- package/src/commands/react/scope-context.tsx +31 -0
- package/src/commands/react/scope-provider.tsx +123 -0
- package/src/commands/react/store.ts +182 -0
- package/src/commands/registry/create.ts +55 -0
- package/src/commands/registry/types.ts +12 -0
- package/src/commands/when/evaluate.ts +80 -0
- package/src/commands/when/index.ts +19 -0
- package/src/commands/when/parse.ts +276 -0
- package/src/commands/when/types.ts +24 -0
- package/src/commands/when/validate.ts +20 -0
- package/src/crdt/awareness.ts +29 -0
- package/src/crdt/bytes.ts +26 -0
- package/src/crdt/connect.ts +285 -0
- package/src/crdt/diff.ts +86 -0
- package/src/crdt/doc.ts +67 -0
- package/src/crdt/front-matter.ts +45 -0
- package/src/crdt/index.ts +76 -0
- package/src/crdt/persist.ts +40 -0
- package/src/crdt/protocol.ts +119 -0
- package/src/crdt/shared.ts +104 -0
- package/src/crdt/text-body.ts +125 -0
- package/src/crdt/undo.ts +59 -0
- package/src/crdt/vite-plugin.ts +42 -0
- package/src/crdt/yjs.ts +16 -0
- package/src/editor-common.ts +160 -0
- package/src/editor-id.ts +56 -0
- package/src/frame-attributes.ts +9 -0
- package/src/index.ts +229 -0
- package/src/json-schema.ts +13 -0
- package/src/layout/bind-toolbar.ts +60 -0
- package/src/layout/catalog.ts +82 -0
- package/src/layout/chrome/command-affordances.ts +47 -0
- package/src/layout/chrome/command-layout-state.ts +42 -0
- package/src/layout/chrome/editor-menu.tsx +358 -0
- package/src/layout/chrome/editor-shortcut-keys.tsx +27 -0
- package/src/layout/chrome/menu-styles.ts +3 -0
- package/src/layout/chrome/mid-menu-nodes.ts +27 -0
- package/src/layout/chrome/use-editor-command-affordances.ts +33 -0
- package/src/layout/chrome/visible-menu-leaves.ts +15 -0
- package/src/layout/content-error-notice.tsx +31 -0
- package/src/layout/define-editor-layout.ts +60 -0
- package/src/layout/define-editor-layout.types.ts +57 -0
- package/src/layout/doc-undo-bridge.tsx +22 -0
- package/src/layout/dock-zones.tsx +61 -0
- package/src/layout/drop.ts +39 -0
- package/src/layout/editor-layout.tsx +287 -0
- package/src/layout/editor-loading.tsx +23 -0
- package/src/layout/frame-layout-binding.tsx +74 -0
- package/src/layout/frame.ts +23 -0
- package/src/layout/hide.ts +103 -0
- package/src/layout/index.ts +14 -0
- package/src/layout/layout-binding.tsx +42 -0
- package/src/layout/layout-storage-key.ts +6 -0
- package/src/layout/layout-values.ts +48 -0
- package/src/layout/mutate.ts +486 -0
- package/src/layout/nesting-context.tsx +20 -0
- package/src/layout/panel-header-action.tsx +29 -0
- package/src/layout/panel-header.tsx +168 -0
- package/src/layout/panel-hide-menu.tsx +6 -0
- package/src/layout/panel-menu.tsx +85 -0
- package/src/layout/panel-section.tsx +114 -0
- package/src/layout/panel-size.ts +94 -0
- package/src/layout/panel-title.tsx +5 -0
- package/src/layout/registry.tsx +333 -0
- package/src/layout/restore-panel.ts +26 -0
- package/src/layout/slots-context.tsx +189 -0
- package/src/layout/tab-button.tsx +60 -0
- package/src/layout/toolbar-scroll.ts +22 -0
- package/src/layout/toolbar.tsx +168 -0
- package/src/layout/types.ts +41 -0
- package/src/layout/use-collapsed-sections.ts +50 -0
- package/src/layout/validate-layout-spec.ts +143 -0
- package/src/message-samples.ts +377 -0
- package/src/origin-headers.ts +57 -0
- package/src/print/index.ts +10 -0
- package/src/print/inspect.ts +16 -0
- package/src/print/schema.ts +122 -0
- package/src/protocol-entry.ts +19 -0
- package/src/protocol.ts +471 -0
- package/src/render-result.ts +25 -0
- package/src/view/editor-chrome.ts +28 -0
- package/src/view/hooks.ts +260 -0
- package/src/view/mount-editor-view.ts +36 -0
- package/src/view/port-binding.ts +127 -0
- package/src/view/port-commands.ts +36 -0
- package/src/view/port.ts +35 -0
- package/src/view/theme-class.ts +11 -0
- package/src/view.ts +313 -0
- package/README.md +0 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, type ReactNode } from "react";
|
|
2
|
+
import type { ClipboardAdapter } from "../clipboard/types";
|
|
3
|
+
import type { ContributionManifest } from "../manifest/types";
|
|
4
|
+
import type { CommandHandler, CommandId } from "../registry/types";
|
|
5
|
+
import { ScopeContext, type ScopeContextValue } from "./scope-context";
|
|
6
|
+
import { useCommandsStore } from "./store";
|
|
7
|
+
|
|
8
|
+
export type ScopeProviderProps = {
|
|
9
|
+
manifest: ContributionManifest;
|
|
10
|
+
/** When false, does not claim active scope. Default true. */
|
|
11
|
+
active?: boolean;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Declares an active contribution scope for the subtree.
|
|
17
|
+
* Publishes the scope onto the commands store so host-level consumers
|
|
18
|
+
* (keybindings bridge, etc.) can merge against it.
|
|
19
|
+
*/
|
|
20
|
+
export function ScopeProvider({
|
|
21
|
+
manifest,
|
|
22
|
+
active = true,
|
|
23
|
+
children,
|
|
24
|
+
}: ScopeProviderProps) {
|
|
25
|
+
const store = useCommandsStore();
|
|
26
|
+
|
|
27
|
+
if (manifest.activation != null && manifest.activation !== "exact") {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`ScopeProvider manifest "${manifest.id}" must use activation "exact"; got "${manifest.activation}"`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const resolvedManifest = useMemo(
|
|
34
|
+
() =>
|
|
35
|
+
manifest.activation === "exact"
|
|
36
|
+
? manifest
|
|
37
|
+
: { ...manifest, activation: "exact" as const },
|
|
38
|
+
[manifest],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const scopeId = resolvedManifest.id;
|
|
42
|
+
const stack = store.getUndoStack(scopeId);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
store.setScope(resolvedManifest, active);
|
|
46
|
+
return () => {
|
|
47
|
+
store.setScope(null, false);
|
|
48
|
+
};
|
|
49
|
+
}, [store, resolvedManifest, active]);
|
|
50
|
+
|
|
51
|
+
const execute = useCallback(
|
|
52
|
+
async (id: CommandId, args?: unknown) => {
|
|
53
|
+
await store.registry.execute(id, args);
|
|
54
|
+
},
|
|
55
|
+
[store.registry],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const registerHandler = useCallback(
|
|
59
|
+
(id: CommandId, handler: CommandHandler) => {
|
|
60
|
+
return store.registry.registerHandler(id, handler);
|
|
61
|
+
},
|
|
62
|
+
[store.registry],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const hasHandler = useCallback(
|
|
66
|
+
(id: CommandId) => store.registry.hasHandler(id),
|
|
67
|
+
[store.registry],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const setClipboardAdapter = useCallback(
|
|
71
|
+
(adapter: ClipboardAdapter | null) => {
|
|
72
|
+
store.setClipboardAdapter(adapter);
|
|
73
|
+
},
|
|
74
|
+
[store],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const unsubs = [
|
|
79
|
+
registerHandler("editor.undo", async () => {
|
|
80
|
+
await store.getUndoStack(scopeId).undo();
|
|
81
|
+
}),
|
|
82
|
+
registerHandler("editor.redo", async () => {
|
|
83
|
+
await store.getUndoStack(scopeId).redo();
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
return () => {
|
|
87
|
+
for (const unsub of unsubs) unsub();
|
|
88
|
+
};
|
|
89
|
+
}, [registerHandler, store, scopeId]);
|
|
90
|
+
|
|
91
|
+
const value = useMemo<ScopeContextValue>(
|
|
92
|
+
() => ({
|
|
93
|
+
scopeId,
|
|
94
|
+
manifest: resolvedManifest,
|
|
95
|
+
active,
|
|
96
|
+
stack,
|
|
97
|
+
execute,
|
|
98
|
+
registerHandler,
|
|
99
|
+
hasHandler,
|
|
100
|
+
setClipboardAdapter,
|
|
101
|
+
}),
|
|
102
|
+
[
|
|
103
|
+
scopeId,
|
|
104
|
+
resolvedManifest,
|
|
105
|
+
active,
|
|
106
|
+
stack,
|
|
107
|
+
execute,
|
|
108
|
+
registerHandler,
|
|
109
|
+
hasHandler,
|
|
110
|
+
setClipboardAdapter,
|
|
111
|
+
],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<ScopeContext.Provider value={value}>{children}</ScopeContext.Provider>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
useOptionalScope,
|
|
121
|
+
useScope,
|
|
122
|
+
type ScopeContextValue,
|
|
123
|
+
} from "./scope-context";
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ClipboardAdapter } from "../clipboard/types";
|
|
3
|
+
import { createUndoStack, type UndoStack } from "../history/stack";
|
|
4
|
+
import type { ContributionManifest } from "../manifest/types";
|
|
5
|
+
import { createCommandRegistry } from "../registry/create";
|
|
6
|
+
import type { ContextKeys, ContextValue } from "../when/types";
|
|
7
|
+
|
|
8
|
+
export type CommandsStore = {
|
|
9
|
+
manifests: ContributionManifest[];
|
|
10
|
+
/** Scope overlay published by ScopeProvider for host-level consumers. */
|
|
11
|
+
scopeManifest: ContributionManifest | null;
|
|
12
|
+
activeScopeId: string | null;
|
|
13
|
+
subscribe: (listener: () => void) => () => void;
|
|
14
|
+
getSnapshot: () => number;
|
|
15
|
+
setManifests: (manifests: ContributionManifest[]) => void;
|
|
16
|
+
setScope: (
|
|
17
|
+
manifest: ContributionManifest | null,
|
|
18
|
+
active: boolean,
|
|
19
|
+
) => void;
|
|
20
|
+
registry: ReturnType<typeof createCommandRegistry>;
|
|
21
|
+
getUndoStack: (scopeId: string) => UndoStack;
|
|
22
|
+
replaceUndoStack: (scopeId: string, stack: UndoStack | null) => void;
|
|
23
|
+
setClipboardAdapter: (adapter: ClipboardAdapter | null) => void;
|
|
24
|
+
getClipboardAdapter: () => ClipboardAdapter | null;
|
|
25
|
+
getContext: () => ContextKeys;
|
|
26
|
+
setContext: (partial: ContextKeys) => void;
|
|
27
|
+
clearContext: (keys?: readonly string[]) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function wrapUndoStack(inner: UndoStack, emit: () => void): UndoStack {
|
|
31
|
+
return {
|
|
32
|
+
push(command) {
|
|
33
|
+
inner.push(command);
|
|
34
|
+
emit();
|
|
35
|
+
},
|
|
36
|
+
async undo() {
|
|
37
|
+
const ok = await inner.undo();
|
|
38
|
+
emit();
|
|
39
|
+
return ok;
|
|
40
|
+
},
|
|
41
|
+
async redo() {
|
|
42
|
+
const ok = await inner.redo();
|
|
43
|
+
emit();
|
|
44
|
+
return ok;
|
|
45
|
+
},
|
|
46
|
+
canUndo() {
|
|
47
|
+
return inner.canUndo();
|
|
48
|
+
},
|
|
49
|
+
canRedo() {
|
|
50
|
+
return inner.canRedo();
|
|
51
|
+
},
|
|
52
|
+
clear() {
|
|
53
|
+
inner.clear();
|
|
54
|
+
emit();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createCommandsStore(
|
|
60
|
+
initialManifests: ContributionManifest[],
|
|
61
|
+
): CommandsStore {
|
|
62
|
+
let manifests = initialManifests;
|
|
63
|
+
let scopeManifest: ContributionManifest | null = null;
|
|
64
|
+
let activeScopeId: string | null = null;
|
|
65
|
+
let version = 0;
|
|
66
|
+
const listeners = new Set<() => void>();
|
|
67
|
+
const registry = createCommandRegistry();
|
|
68
|
+
const undoStacks = new Map<string, UndoStack>();
|
|
69
|
+
let clipboardAdapter: ClipboardAdapter | null = null;
|
|
70
|
+
let context: Record<string, ContextValue> = {};
|
|
71
|
+
|
|
72
|
+
const emit = () => {
|
|
73
|
+
version += 1;
|
|
74
|
+
for (const listener of listeners) listener();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get manifests() {
|
|
79
|
+
return manifests;
|
|
80
|
+
},
|
|
81
|
+
get scopeManifest() {
|
|
82
|
+
return scopeManifest;
|
|
83
|
+
},
|
|
84
|
+
get activeScopeId() {
|
|
85
|
+
return activeScopeId;
|
|
86
|
+
},
|
|
87
|
+
subscribe(listener) {
|
|
88
|
+
listeners.add(listener);
|
|
89
|
+
return () => {
|
|
90
|
+
listeners.delete(listener);
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
getSnapshot() {
|
|
94
|
+
return version;
|
|
95
|
+
},
|
|
96
|
+
setManifests(next) {
|
|
97
|
+
manifests = next;
|
|
98
|
+
emit();
|
|
99
|
+
},
|
|
100
|
+
setScope(manifest, active) {
|
|
101
|
+
scopeManifest = manifest;
|
|
102
|
+
activeScopeId = active && manifest ? manifest.id : null;
|
|
103
|
+
emit();
|
|
104
|
+
},
|
|
105
|
+
registry,
|
|
106
|
+
getUndoStack(scopeId) {
|
|
107
|
+
let stack = undoStacks.get(scopeId);
|
|
108
|
+
if (!stack) {
|
|
109
|
+
stack = wrapUndoStack(createUndoStack(), emit);
|
|
110
|
+
undoStacks.set(scopeId, stack);
|
|
111
|
+
}
|
|
112
|
+
return stack;
|
|
113
|
+
},
|
|
114
|
+
replaceUndoStack(scopeId, stack) {
|
|
115
|
+
if (!stack) {
|
|
116
|
+
undoStacks.delete(scopeId);
|
|
117
|
+
emit();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
undoStacks.set(scopeId, wrapUndoStack(stack, emit));
|
|
121
|
+
emit();
|
|
122
|
+
},
|
|
123
|
+
setClipboardAdapter(adapter) {
|
|
124
|
+
clipboardAdapter = adapter;
|
|
125
|
+
},
|
|
126
|
+
getClipboardAdapter() {
|
|
127
|
+
return clipboardAdapter;
|
|
128
|
+
},
|
|
129
|
+
getContext() {
|
|
130
|
+
return context;
|
|
131
|
+
},
|
|
132
|
+
setContext(partial) {
|
|
133
|
+
let changed = false;
|
|
134
|
+
const next = { ...context };
|
|
135
|
+
for (const [key, value] of Object.entries(partial)) {
|
|
136
|
+
if (value === undefined) {
|
|
137
|
+
if (key in next) {
|
|
138
|
+
delete next[key];
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (next[key] !== value) {
|
|
144
|
+
next[key] = value;
|
|
145
|
+
changed = true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!changed) return;
|
|
149
|
+
context = next;
|
|
150
|
+
emit();
|
|
151
|
+
},
|
|
152
|
+
clearContext(keys) {
|
|
153
|
+
if (keys == null) {
|
|
154
|
+
if (Object.keys(context).length === 0) return;
|
|
155
|
+
context = {};
|
|
156
|
+
emit();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
let changed = false;
|
|
160
|
+
const next = { ...context };
|
|
161
|
+
for (const key of keys) {
|
|
162
|
+
if (key in next) {
|
|
163
|
+
delete next[key];
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!changed) return;
|
|
168
|
+
context = next;
|
|
169
|
+
emit();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const CommandsStoreContext = createContext<CommandsStore | null>(null);
|
|
175
|
+
|
|
176
|
+
export function useCommandsStore(): CommandsStore {
|
|
177
|
+
const store = useContext(CommandsStoreContext);
|
|
178
|
+
if (!store) {
|
|
179
|
+
throw new Error("Commands hooks require <CommandsProvider>");
|
|
180
|
+
}
|
|
181
|
+
return store;
|
|
182
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommandDefinition,
|
|
3
|
+
CommandHandler,
|
|
4
|
+
CommandId,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
export type CommandRegistry = {
|
|
8
|
+
get(id: CommandId): CommandDefinition | undefined;
|
|
9
|
+
list(): CommandDefinition[];
|
|
10
|
+
define(command: CommandDefinition): void;
|
|
11
|
+
registerHandler(id: CommandId, handler: CommandHandler): () => void;
|
|
12
|
+
execute(id: CommandId, args?: unknown): Promise<void>;
|
|
13
|
+
hasHandler(id: CommandId): boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createCommandRegistry(): CommandRegistry {
|
|
17
|
+
const definitions = new Map<CommandId, CommandDefinition>();
|
|
18
|
+
const handlers = new Map<CommandId, CommandHandler>();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
get(id) {
|
|
22
|
+
return definitions.get(id);
|
|
23
|
+
},
|
|
24
|
+
list() {
|
|
25
|
+
return [...definitions.values()];
|
|
26
|
+
},
|
|
27
|
+
define(command) {
|
|
28
|
+
const existing = definitions.get(command.id);
|
|
29
|
+
if (existing && existing.sourceId !== command.sourceId) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Command "${command.id}" already defined by "${existing.sourceId}"`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
definitions.set(command.id, command);
|
|
35
|
+
},
|
|
36
|
+
registerHandler(id, handler) {
|
|
37
|
+
handlers.set(id, handler);
|
|
38
|
+
return () => {
|
|
39
|
+
if (handlers.get(id) === handler) {
|
|
40
|
+
handlers.delete(id);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
async execute(id, args) {
|
|
45
|
+
const handler = handlers.get(id);
|
|
46
|
+
if (!handler) {
|
|
47
|
+
throw new Error(`No handler registered for command "${id}"`);
|
|
48
|
+
}
|
|
49
|
+
await handler(args);
|
|
50
|
+
},
|
|
51
|
+
hasHandler(id) {
|
|
52
|
+
return handlers.has(id);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type CommandId = string;
|
|
2
|
+
|
|
3
|
+
export type CommandHandler = (args?: unknown) => void | Promise<void>;
|
|
4
|
+
|
|
5
|
+
export type CommandDefinition = {
|
|
6
|
+
id: CommandId;
|
|
7
|
+
title: string;
|
|
8
|
+
/** Palette / menu grouping label. */
|
|
9
|
+
group?: string;
|
|
10
|
+
/** Manifest that contributed this command. */
|
|
11
|
+
sourceId: string;
|
|
12
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ContextKeys, ContextValue, WhenExpr } from "./types";
|
|
2
|
+
|
|
3
|
+
function readKey(ctx: ContextKeys, name: string): ContextValue {
|
|
4
|
+
return ctx[name];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isTruthy(value: ContextValue): boolean {
|
|
8
|
+
if (value === undefined) return false;
|
|
9
|
+
if (typeof value === "boolean") return value;
|
|
10
|
+
if (typeof value === "number") return value !== 0;
|
|
11
|
+
return value.length > 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function asNumber(value: ContextValue): number | null {
|
|
15
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
16
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
17
|
+
const n = Number(value);
|
|
18
|
+
return Number.isFinite(n) ? n : null;
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function valuesEqual(left: ContextValue, right: ContextValue): boolean {
|
|
25
|
+
if (left === undefined || right === undefined) {
|
|
26
|
+
return left === right;
|
|
27
|
+
}
|
|
28
|
+
if (typeof left === typeof right) {
|
|
29
|
+
return left === right;
|
|
30
|
+
}
|
|
31
|
+
// Loose compare across string/number/boolean for manifest ergonomics.
|
|
32
|
+
return String(left) === String(right);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function evalValue(expr: WhenExpr, ctx: ContextKeys): ContextValue {
|
|
36
|
+
switch (expr.kind) {
|
|
37
|
+
case "literal":
|
|
38
|
+
return expr.value;
|
|
39
|
+
case "key":
|
|
40
|
+
return readKey(ctx, expr.name);
|
|
41
|
+
case "unary":
|
|
42
|
+
return !isTruthy(evalValue(expr.arg, ctx));
|
|
43
|
+
case "binary": {
|
|
44
|
+
if (expr.op === "&&") {
|
|
45
|
+
const left = evalValue(expr.left, ctx);
|
|
46
|
+
if (!isTruthy(left)) return false;
|
|
47
|
+
return isTruthy(evalValue(expr.right, ctx));
|
|
48
|
+
}
|
|
49
|
+
if (expr.op === "||") {
|
|
50
|
+
const left = evalValue(expr.left, ctx);
|
|
51
|
+
if (isTruthy(left)) return true;
|
|
52
|
+
return isTruthy(evalValue(expr.right, ctx));
|
|
53
|
+
}
|
|
54
|
+
if (expr.op === "==") {
|
|
55
|
+
return valuesEqual(evalValue(expr.left, ctx), evalValue(expr.right, ctx));
|
|
56
|
+
}
|
|
57
|
+
if (expr.op === "!=") {
|
|
58
|
+
return !valuesEqual(evalValue(expr.left, ctx), evalValue(expr.right, ctx));
|
|
59
|
+
}
|
|
60
|
+
const ln = asNumber(evalValue(expr.left, ctx));
|
|
61
|
+
const rn = asNumber(evalValue(expr.right, ctx));
|
|
62
|
+
if (ln == null || rn == null) return false;
|
|
63
|
+
switch (expr.op) {
|
|
64
|
+
case "<":
|
|
65
|
+
return ln < rn;
|
|
66
|
+
case "<=":
|
|
67
|
+
return ln <= rn;
|
|
68
|
+
case ">":
|
|
69
|
+
return ln > rn;
|
|
70
|
+
case ">=":
|
|
71
|
+
return ln >= rn;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Evaluate a parsed when expression against a context key bag. */
|
|
78
|
+
export function evaluateWhen(expr: WhenExpr, ctx: ContextKeys): boolean {
|
|
79
|
+
return isTruthy(evalValue(expr, ctx));
|
|
80
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { parseWhen } from "./parse";
|
|
2
|
+
export { evaluateWhen } from "./evaluate";
|
|
3
|
+
export { validateWhenClause, type WhenValidationIssue } from "./validate";
|
|
4
|
+
export {
|
|
5
|
+
WhenParseError,
|
|
6
|
+
type ContextKeys,
|
|
7
|
+
type ContextValue,
|
|
8
|
+
type WhenExpr,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
import { parseWhen } from "./parse";
|
|
12
|
+
import { evaluateWhen } from "./evaluate";
|
|
13
|
+
import type { ContextKeys } from "./types";
|
|
14
|
+
|
|
15
|
+
/** Parse + evaluate in one step. Throws WhenParseError if the clause is invalid. */
|
|
16
|
+
export function isWhenEnabled(source: string | undefined, ctx: ContextKeys): boolean {
|
|
17
|
+
if (source == null || source.trim() === "") return true;
|
|
18
|
+
return evaluateWhen(parseWhen(source), ctx);
|
|
19
|
+
}
|