@moloodev/editor-sdk 0.10.0 → 0.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moloodev/editor-sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/view.ts",
@@ -29,8 +29,8 @@
29
29
  "y-protocols": "^1.0.7",
30
30
  "yjs": "^13.6.32",
31
31
  "zod": "^4.5.4",
32
- "@moloodev/platform": "0.10.0",
33
- "@moloodev/ui": "0.10.0"
32
+ "@moloodev/platform": "0.11.0",
33
+ "@moloodev/ui": "0.11.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": "^19.0.0",
@@ -46,9 +46,9 @@
46
46
  "jsdom": "30.1.1",
47
47
  "react": "^19.2.8",
48
48
  "react-dom": "^19.2.8",
49
- "tsx": "^4.23.13",
49
+ "vitest": "5.1.0",
50
50
  "typescript": "^7.0.2",
51
- "@moloodev/typescript-config": "0.10.0"
51
+ "@moloodev/typescript-config": "0.11.0"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public"
@@ -60,7 +60,7 @@
60
60
  ],
61
61
  "scripts": {
62
62
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.origin-headers.json",
63
- "test": "tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx' && pnpm test:browser",
64
- "test:browser": "tsx --test 'browser/**/*.test.ts'"
63
+ "test": "vitest run --root ../.. --project @moloodev/editor-sdk --project @moloodev/editor-sdk:browser",
64
+ "test:browser": "vitest run --root ../.. --project @moloodev/editor-sdk:browser"
65
65
  }
66
66
  }
@@ -10,6 +10,8 @@ const HOST_COMMANDS = new Set([
10
10
  "editor.new",
11
11
  "editor.open",
12
12
  "editor.preferences",
13
+ "editor.about",
14
+ "editor.pinToSidebar",
13
15
  ]);
14
16
 
15
17
  const whenClause = z
@@ -1,6 +1,14 @@
1
1
  import type { ComponentType } from "react";
2
2
  import { parseTablerIconUri } from "../manifest/editor-document";
3
3
  import {
4
+ IconArchive,
5
+ IconCube,
6
+ IconDatabase,
7
+ IconFile,
8
+ IconFolder,
9
+ IconHelp,
10
+ IconMovie,
11
+ IconTypography,
4
12
  IconArrowBackUp,
5
13
  IconArrowForwardUp,
6
14
  IconBold,
@@ -69,6 +77,14 @@ export type EditorIconComponent = ComponentType<IconProps>;
69
77
 
70
78
  /** Host-resolved Tabler icon names. */
71
79
  export const EDITOR_ICON_CATALOG = {
80
+ archive: IconArchive,
81
+ cube: IconCube,
82
+ database: IconDatabase,
83
+ file: IconFile,
84
+ folder: IconFolder,
85
+ help: IconHelp,
86
+ movie: IconMovie,
87
+ typography: IconTypography,
72
88
  menu: IconMenu2,
73
89
  "arrow-back-up": IconArrowBackUp,
74
90
  restore: IconRestore,
@@ -10,7 +10,7 @@ import { validateManifest } from "./validate-manifest";
10
10
 
11
11
  export const TABLER_ICON_URI_PREFIX = "tabler:";
12
12
  const TABLER_ICON_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
13
- const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
13
+ export const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
14
14
 
15
15
  export function parseTablerIconUri(value: string): string | null {
16
16
  if (!value.startsWith(TABLER_ICON_URI_PREFIX)) return null;
@@ -28,6 +28,8 @@ export type EditorContributes = Pick<
28
28
  >;
29
29
 
30
30
  export type EditorFileMatcher = {
31
+ /** Explicit asset kind id for type-only displays, independent of acceptedTypes. */
32
+ type?: string;
31
33
  /** File extensions with leading dot, supporting compound extensions (e.g. [".layout.json"], [".md", ".markdown"]). */
32
34
  extensions?: string[];
33
35
  /** MIME types or wildcard families (e.g. ["text/markdown"], ["application/json"]). */
@@ -56,6 +58,8 @@ export type EditorManifest = {
56
58
  categories?: string[];
57
59
  /** Host-resolved Tabler icon URI (`tabler:{name}`). */
58
60
  icon?: string;
61
+ /** Editor identity tint (`#rrggbb`), independent of fileTypes. */
62
+ color?: string;
59
63
  /** Optional host I/O the editor implements (`editor.import` / `editor.download`). */
60
64
  capabilities?: EditorCapability[];
61
65
  importAccept?: string;
@@ -117,6 +121,13 @@ function parseEditorFileMatchers(input: unknown): EditorFileMatcher[] {
117
121
  `Invalid editor manifest: fileTypes.${index} must be an object`,
118
122
  );
119
123
  }
124
+ let type: string | undefined;
125
+ if (entry.type != null) {
126
+ type = requireString(entry.type, `fileTypes.${index}.type`);
127
+ if (!isQualifiedName(type)) {
128
+ throw new Error(`Invalid editor manifest: fileTypes.${index}.type must be publisher.name`);
129
+ }
130
+ }
120
131
  let extensions: string[] | undefined;
121
132
  if (entry.extensions != null) {
122
133
  if (!Array.isArray(entry.extensions) || entry.extensions.length === 0) {
@@ -198,18 +209,19 @@ function parseEditorFileMatchers(input: unknown): EditorFileMatcher[] {
198
209
  let icon: AssetTypeIcon | undefined;
199
210
  if (entry.icon != null) {
200
211
  const rawIcon = requireString(entry.icon, `fileTypes.${index}.icon`);
201
- if (parseTablerIconUri(rawIcon) === null) {
212
+ const name = parseTablerIconUri(rawIcon);
213
+ if (name === null) {
202
214
  throw new Error(
203
215
  `Invalid editor manifest: fileTypes.${index}.icon must be tabler:{name}`,
204
216
  );
205
217
  }
206
- icon = rawIcon as AssetTypeIcon;
218
+ icon = `tabler:${name}`;
207
219
  }
208
220
 
209
221
  let color: string | undefined;
210
222
  if (entry.color != null) {
211
223
  const rawColor = requireString(entry.color, `fileTypes.${index}.color`);
212
- if (!HEX_COLOR.test(rawColor)) {
224
+ if (!HEX_COLOR_PATTERN.test(rawColor)) {
213
225
  throw new Error(
214
226
  `Invalid editor manifest: fileTypes.${index}.color must be #rrggbb`,
215
227
  );
@@ -218,6 +230,7 @@ function parseEditorFileMatchers(input: unknown): EditorFileMatcher[] {
218
230
  }
219
231
 
220
232
  return {
233
+ ...(type ? { type } : {}),
221
234
  ...(extensions ? { extensions } : {}),
222
235
  ...(mimeTypes ? { mimeTypes } : {}),
223
236
  ...(schema ? { schema } : {}),
@@ -370,6 +383,14 @@ export function validateEditorManifest(
370
383
  throw new Error("Invalid editor manifest: icon must be tabler:{name}");
371
384
  }
372
385
  }
386
+ let color: string | undefined;
387
+ if (input.color != null) {
388
+ const rawColor = requireString(input.color, "color");
389
+ if (!HEX_COLOR_PATTERN.test(rawColor)) {
390
+ throw new Error("Invalid editor manifest: color must be #rrggbb");
391
+ }
392
+ color = rawColor.toLowerCase();
393
+ }
373
394
  if ("order" in input) {
374
395
  throw new Error(
375
396
  "Invalid editor manifest: order belongs on workspace favorites, not editor documents",
@@ -438,6 +459,7 @@ export function validateEditorManifest(
438
459
  ...(fileTypes ? { fileTypes } : {}),
439
460
  ...(templates ? { templates } : {}),
440
461
  ...(icon ? { icon } : {}),
462
+ ...(color ? { color } : {}),
441
463
  ...(categories ? { categories } : {}),
442
464
  ...(capabilities ? { capabilities } : {}),
443
465
  ...(importAccept ? { importAccept } : {}),
@@ -111,6 +111,7 @@ function buildMenuTree(
111
111
  }
112
112
 
113
113
  const SEGMENT_META: Record<string, { label: string; order: number }> = {
114
+ app: { label: "앱", order: 0 },
114
115
  file: { label: "파일", order: 10 },
115
116
  edit: { label: "편집", order: 30 },
116
117
  insert: { label: "삽입", order: 35 },
@@ -63,7 +63,7 @@ export const EDITOR_COMMON_CONTRIBUTES = {
63
63
  },
64
64
  {
65
65
  id: "editor.preferences",
66
- title: "에디터 설정...",
66
+ title: "에디터 설정…",
67
67
  group: "앱",
68
68
  },
69
69
  {
@@ -140,10 +140,10 @@ export const EDITOR_COMMON_CONTRIBUTES = {
140
140
  shortcut: "Backspace",
141
141
  },
142
142
  {
143
- path: ["edit", "preferences"],
143
+ path: ["app", "preferences"],
144
144
  commandId: "editor.preferences",
145
- group: "9_settings",
146
- order: 20,
145
+ group: "2_settings",
146
+ order: 10,
147
147
  },
148
148
  {
149
149
  path: ["view", "resetLayout"],
@@ -22,6 +22,9 @@ export function isHostReservedKey(key: string): boolean {
22
22
 
23
23
  const HOST_CHROME_SCOPE = "host-chrome";
24
24
 
25
+ /** The host owns this label; published views still send their own title ("환경설정"). */
26
+ export const PREFERENCES_LABEL = "에디터 설정…";
27
+
25
28
  export function scopeManifest(
26
29
  manifest: ChromeManifest,
27
30
  activation?: "exact",
@@ -33,7 +36,13 @@ export function scopeManifest(
33
36
  ? { commands: manifest.contributes.commands }
34
37
  : {}),
35
38
  ...(manifest.contributes?.menus
36
- ? { menus: manifest.contributes.menus }
39
+ ? { menus: manifest.contributes.menus.map((menu) =>
40
+ // Compat for published views that still contribute the old Edit-menu path.
41
+ // Remove once no published editor targets an SDK older than this release.
42
+ menu.commandId === "editor.preferences"
43
+ ? { ...menu, path: ["app", "preferences"], group: "2_settings", order: 10, label: PREFERENCES_LABEL }
44
+ : menu,
45
+ ) }
37
46
  : {}),
38
47
  ...(manifest.contributes?.keybindings
39
48
  ? { keybindings: manifest.contributes.keybindings }
@@ -43,8 +52,19 @@ export function scopeManifest(
43
52
 
44
53
  export function hostChromeMergeInput(
45
54
  manifest: ChromeManifest,
55
+ options?: { displayName: string },
46
56
  ): ContributionManifest[] {
47
- return [scopeManifest(manifest)];
57
+ return [{
58
+ id: "host-app-menu",
59
+ commands: [
60
+ { id: "editor.about", title: `${options?.displayName ?? "앱"}에 대하여` },
61
+ { id: "editor.pinToSidebar", title: "사이드바에 고정" },
62
+ ],
63
+ menus: [
64
+ { path: ["app", "about"], commandId: "editor.about", group: "1_about", order: 10 },
65
+ { path: ["app", "pin"], commandId: "editor.pinToSidebar", group: "3_sidebar", order: 10 },
66
+ ],
67
+ }, scopeManifest(manifest)];
48
68
  }
49
69
 
50
70
  export type CommandStateBody = Extract<
@@ -14,6 +14,7 @@ import { KindIcon } from "@moloodev/ui/kind-icon";
14
14
  import { TooltipProvider } from "@moloodev/ui";
15
15
  import {
16
16
  hostChromeMergeInput,
17
+ PREFERENCES_LABEL,
17
18
  scopeManifest,
18
19
  validateHostChrome,
19
20
  type CommandStateBody,
@@ -25,6 +26,8 @@ export type HostChromeHandlers = {
25
26
  openStartDialog: () => void;
26
27
  openBrowser: () => void;
27
28
  openPreferences?: () => void;
29
+ openAbout?: () => void;
30
+ togglePinToSidebar?: () => void;
28
31
  /** When set, `editor.import` opens the host file picker instead of reaching the view. */
29
32
  openImport?: () => void;
30
33
  /** Runs before `editor.resetLayout` is forwarded to the view. */
@@ -33,6 +36,7 @@ export type HostChromeHandlers = {
33
36
 
34
37
  export type EditorHostChromeProps = {
35
38
  value: unknown;
39
+ appMenu?: { displayName: string; pinned: boolean };
36
40
  host: HostChromeHandlers;
37
41
  onViewCommand: (commandId: string) => void;
38
42
  commandState?: Readonly<Record<string, CommandStateBody>>;
@@ -104,7 +108,7 @@ function overlayCommandState(
104
108
  visible: state.visible,
105
109
  disabled: state.disabled,
106
110
  pressed: state.pressed,
107
- label: state.label,
111
+ label: id === "editor.preferences" ? PREFERENCES_LABEL : state.label,
108
112
  };
109
113
  }
110
114
  return next ?? commands;
@@ -117,6 +121,14 @@ export function dispatchHostCommand(
117
121
  onViewCommand: (commandId: string) => void,
118
122
  ): void {
119
123
  if (!declared.has(commandId)) return;
124
+ if (commandId === "editor.about") {
125
+ host.openAbout?.();
126
+ return;
127
+ }
128
+ if (commandId === "editor.pinToSidebar") {
129
+ host.togglePinToSidebar?.();
130
+ return;
131
+ }
120
132
  if (commandId === "editor.new") {
121
133
  host.openStartDialog();
122
134
  return;
@@ -152,18 +164,16 @@ export function chromeCommandHandlers(
152
164
  }
153
165
 
154
166
  function HostCommandHandlers({
155
- manifest,
156
167
  host,
157
168
  onViewCommand,
158
169
  }: {
159
- manifest: ChromeManifest;
160
170
  host: HostChromeHandlers;
161
171
  onViewCommand: (commandId: string) => void;
162
172
  }) {
163
- const { registerHandler } = useCommands();
173
+ const { commands, registerHandler } = useCommands();
164
174
  const commandIds = useMemo(
165
- () => manifest.contributes?.commands?.map((command) => command.id) ?? [],
166
- [manifest],
175
+ () => commands.map((command) => command.id),
176
+ [commands],
167
177
  );
168
178
  const declared = useMemo(() => new Set(commandIds), [commandIds]);
169
179
  useEffect(() => {
@@ -198,18 +208,20 @@ function HostCommands({
198
208
  <ScopeProvider manifest={scopeManifest(manifest, "exact")}>
199
209
  {children}
200
210
  </ScopeProvider>
201
- <HostCommandHandlers manifest={manifest} host={host} onViewCommand={onViewCommand} />
211
+ <HostCommandHandlers host={host} onViewCommand={onViewCommand} />
202
212
  </>
203
213
  );
204
214
  }
205
215
 
206
216
  function ChromeFrame({
207
217
  spec,
218
+ appMenu,
208
219
  host,
209
220
  onViewCommand,
210
221
  commandState,
211
222
  }: {
212
223
  spec: Spec;
224
+ appMenu?: EditorHostChromeProps["appMenu"];
213
225
  host: HostChromeHandlers;
214
226
  onViewCommand: (commandId: string) => void;
215
227
  commandState?: Readonly<Record<string, CommandStateBody>>;
@@ -221,8 +233,18 @@ function ChromeFrame({
221
233
  [commands],
222
234
  );
223
235
  const layoutCommands = useMemo(
224
- () => overlayCommandState(commandLayoutState(commands, ctx).commands, commandState),
225
- [commandState, commands, ctx],
236
+ () => ({
237
+ ...overlayCommandState(commandLayoutState(commands, ctx).commands, commandState),
238
+ "editor.about": {
239
+ visible: true, disabled: !host.openAbout, pressed: false,
240
+ label: `${appMenu?.displayName ?? "앱"}에 대하여`,
241
+ },
242
+ "editor.pinToSidebar": {
243
+ visible: true, disabled: !host.togglePinToSidebar,
244
+ pressed: appMenu?.pinned ?? false, label: "사이드바에 고정",
245
+ },
246
+ }),
247
+ [appMenu, commandState, commands, ctx, host.openAbout, host.togglePinToSidebar],
226
248
  );
227
249
  const store = useMemo(() => createStateStore({ commands: layoutCommands }), []);
228
250
  useLayoutEffect(() => {
@@ -264,6 +286,7 @@ function ChromeFrame({
264
286
  return (
265
287
  <LayoutBindingProvider value={binding}>
266
288
  <EditorMenuBar
289
+ appMenuLabel={appMenu?.displayName}
267
290
  commandState={layoutCommands}
268
291
  onExecute={(commandId) => handlers.executeCommand({ commandId })}
269
292
  />
@@ -273,14 +296,15 @@ function ChromeFrame({
273
296
 
274
297
  export function EditorHostChrome({
275
298
  value,
299
+ appMenu,
276
300
  host,
277
301
  onViewCommand,
278
302
  commandState,
279
303
  }: EditorHostChromeProps) {
280
304
  const validated = useMemo(() => validateHostChrome(value), [value]);
281
305
  const seeded = useMemo(
282
- () => (validated.ok ? hostChromeMergeInput(validated.manifest) : []),
283
- [validated],
306
+ () => (validated.ok ? hostChromeMergeInput(validated.manifest, appMenu) : []),
307
+ [validated, appMenu?.displayName],
284
308
  );
285
309
  if (!validated.ok) return null;
286
310
  return (
@@ -293,6 +317,7 @@ export function EditorHostChrome({
293
317
  >
294
318
  <ChromeFrame
295
319
  spec={validated.spec}
320
+ appMenu={appMenu}
296
321
  host={host}
297
322
  onViewCommand={onViewCommand}
298
323
  commandState={commandState}
@@ -34,9 +34,11 @@ type MenuCommandState = Readonly<Record<string, CommandLayoutEntry>>;
34
34
 
35
35
  /** Horizontal editor menubar driven by merged contribution manifests. */
36
36
  export function EditorMenuBar({
37
+ appMenuLabel,
37
38
  commandState,
38
39
  onExecute,
39
40
  }: {
41
+ appMenuLabel?: string;
40
42
  commandState?: MenuCommandState;
41
43
  onExecute?: (commandId: string) => unknown;
42
44
  } = {}) {
@@ -103,7 +105,7 @@ export function EditorMenuBar({
103
105
  />
104
106
  }
105
107
  >
106
- {node.label}
108
+ {node.segment === "app" ? appMenuLabel ?? node.label : node.label}
107
109
  </DropdownMenuTrigger>
108
110
  <DropdownMenuContent
109
111
  align="start"
@@ -298,7 +300,7 @@ export function MenuLeaf({
298
300
  </>
299
301
  );
300
302
 
301
- if (isPanelToggleCommand(node.commandId)) {
303
+ if (isPanelToggleCommand(node.commandId) || node.commandId === "editor.pinToSidebar") {
302
304
  return (
303
305
  <DropdownMenuCheckboxItem
304
306
  className={`${editorMenuItemClass} pr-7`}
@@ -0,0 +1,5 @@
1
+ import { createContext } from "react";
2
+ import type { FileAppearanceMatcher } from "@moloodev/platform";
3
+
4
+ /** Active view manifest declarations, supplied by mountEditorView. */
5
+ export const FileTypesContext = createContext<readonly FileAppearanceMatcher[]>([]);
package/src/view/hooks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
2
- import { KNOWN_PATTERN_IDS, resolvePatternAppearance } from "@moloodev/platform";
2
+ import { KNOWN_PATTERN_IDS, resolveTypeAppearance, type FileAppearanceMatcher } from "@moloodev/platform";
3
3
  import type { Doc } from "../crdt";
4
4
  import type { Composition, Publication } from "@moloodev/platform";
5
5
  import type {
@@ -120,8 +120,8 @@ export function shouldShowContentError(
120
120
  return contentError && isEditorContentReady(mode, hasDoc);
121
121
  }
122
122
 
123
- export function kindOf(type: string) {
124
- const entry = resolvePatternAppearance(type);
123
+ export function kindOf(type: string, matchers: readonly FileAppearanceMatcher[] = []) {
124
+ const entry = resolveTypeAppearance(type, matchers);
125
125
  return {
126
126
  id: type,
127
127
  label: entry.label,
@@ -130,8 +130,9 @@ export function kindOf(type: string) {
130
130
  };
131
131
  }
132
132
 
133
- export function kindsOf() {
134
- return KNOWN_PATTERN_IDS.map(kindOf);
133
+ export function kindsOf(matchers: readonly FileAppearanceMatcher[] = []) {
134
+ const types = new Set<string>([...KNOWN_PATTERN_IDS, ...matchers.flatMap((rule) => rule.type ? [rule.type] : [])]);
135
+ return [...types].map((type) => kindOf(type, matchers));
135
136
  }
136
137
 
137
138
  export function useTheme() {
@@ -2,6 +2,7 @@ import { createElement, type ComponentType } from "react";
2
2
  import type { EditorManifest } from "../commands";
3
3
  import { connectEditorView, type EditorView } from "../view";
4
4
  import { bindThemeClass } from "./theme-class";
5
+ import { FileTypesContext } from "./file-types-context";
5
6
  import { createEditorChrome } from "./editor-chrome";
6
7
 
7
8
  /** Imports outside an iframe stay inert, including server and DOM unit tests. */
@@ -24,7 +25,8 @@ export function mountEditorView(options: {
24
25
  root.id = "root";
25
26
  document.body.append(root);
26
27
  void import("react-dom/client").then(({ createRoot }) => {
27
- createRoot(root).render(createElement(options.Root, { view }));
28
+ createRoot(root).render(createElement(FileTypesContext.Provider, { value: options.manifest.fileTypes ?? [] },
29
+ createElement(options.Root, { view })));
28
30
  });
29
31
  void view.handshake.then(async (reply) => {
30
32
  if (!reply.ok) throw new Error(reply.code);
@@ -1,5 +1,6 @@
1
- import { createElement, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
1
+ import { createElement, useContext, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
2
2
  import type { EditorOperation } from "../index";
3
+ import { FileTypesContext } from "./file-types-context";
3
4
  import { RenderFailure, renderCode } from "../render-result";
4
5
  import type { EditorView } from "../view";
5
6
  import { EditorViewProvider, kindOf, type EditorOpenBrowserArgs, type EditorViewBinding } from "./hooks";
@@ -66,7 +67,8 @@ function createPortBinding(view: EditorView, acceptedTypes: readonly string[]):
66
67
  },
67
68
  useKind(type) {
68
69
  useSnapshot();
69
- return useMemo(() => kindOf(type), [type]);
70
+ const matchers = useContext(FileTypesContext);
71
+ return useMemo(() => kindOf(type, matchers), [type, matchers]);
70
72
  },
71
73
  useKinds: () => unavailable("useKinds"),
72
74
  useList: () => unavailable("useList"),