@public-tauri/raycast-convert 1.0.1 → 1.1.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/src/index.ts CHANGED
@@ -1,73 +1,167 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { installAndBuild } from './build';
4
- import { resolveNoViewCommands } from './commands';
5
- import { copyAssetsDir, readJson, writeJson } from './files';
4
+ import { resolveSupportedCommands } from './commands';
5
+ import {
6
+ copyRaycastViewTemplateAppDist,
7
+ copyRaycastWorkerViewBundle,
8
+ ensureRaycastViewTemplateBuilt,
9
+ findPublicTauriRepoRoot,
10
+ } from './generate/copy-templates';
11
+ import { copyPluginSourceToOutput, readJson, writeJson } from './files';
6
12
  import { generatePublicMain } from './generate/public-main';
7
13
  import { generateServerModule } from './generate/server-module';
8
14
  import { generateTsdownConfig } from './generate/tsdown-config';
9
15
  import { DEFAULT_PLUGIN_ICON, normalizeRaycastIcon } from './icons';
10
16
  import { resolveConvertOptions } from './options';
11
17
  import { createConvertedPackage } from './package-json';
18
+ import { resolveConvertedPackageName } from './package-name';
12
19
  import { mergePreferences } from './preferences';
13
- import type { ConversionReport, ConvertOptions, ConvertWarning, RaycastPackage } from './types';
20
+ import type {
21
+ ConversionReport,
22
+ ConvertOptions,
23
+ ConvertWarning,
24
+ ConvertedCommand,
25
+ RaycastCommandArgument,
26
+ RaycastPackage,
27
+ } from './types';
14
28
 
15
29
  export type * from './types';
30
+ export { RAYCAST_CONVERTED_SCOPE, resolveConvertedPackageName, resolveRaycastSlug, sanitizeSlug } from './package-name';
16
31
 
17
- const createPublicCommands = (commands: { name: string, title?: string, subtitle?: string, description?: string, icon?: string, keywords?: string[] }[], icon: string) => commands.map(command => ({
18
- name: command.name,
19
- title: command.title || command.name,
20
- subtitle: command.subtitle || command.description,
21
- description: command.description,
22
- icon: normalizeRaycastIcon(command.icon) || icon,
23
- mode: 'none',
24
- matches: [
25
- {
26
- type: 'text',
27
- keywords: command.keywords?.length ? command.keywords : [command.title || command.name],
28
- },
29
- ],
30
- }));
32
+ /**
33
+ * `development` 模式下 view 插件的 wujie 入口:指向 `@public-tauri/template` 的 Vite 默认端口下的 `raycast.html`,
34
+ * 便于先 `pnpm --filter @public-tauri/template dev` 再转换插件即可调试 UI,无需模板 `build`/拷贝 `dist/view`。
35
+ * `production` 模式使用 `./dist/view/raycast.html`。
36
+ */
37
+ export const RAYCAST_VIEW_TEMPLATE_DEV_ENTRY = 'http://localhost:5173/raycast.html';
38
+
39
+ const createPublicCommands = (
40
+ commands: ConvertedCommand[],
41
+ icon: string,
42
+ ) => commands.map((command) => {
43
+ const raycastArguments = (command.arguments || []).map((arg: RaycastCommandArgument) => ({
44
+ name: arg.name,
45
+ type: arg.type,
46
+ placeholder: arg.placeholder,
47
+ required: Boolean(arg.required),
48
+ data: arg.type === 'dropdown'
49
+ ? (arg.data || []).map(item => ({ title: item.title || item.value, value: item.value }))
50
+ : undefined,
51
+ }));
52
+ return {
53
+ name: command.name,
54
+ title: command.title || command.name,
55
+ subtitle: command.subtitle || command.description,
56
+ description: command.description,
57
+ icon: normalizeRaycastIcon(command.icon) || icon,
58
+ mode: (command.mode === 'no-view' && raycastArguments.length === 0) ? 'none' : 'view',
59
+ raycastTargetMode: command.mode,
60
+ raycastArguments,
61
+ matches: [
62
+ {
63
+ type: 'text',
64
+ keywords: command.keywords?.length ? command.keywords : [command.title || command.name],
65
+ },
66
+ ],
67
+ };
68
+ });
31
69
 
32
70
  export const convertRaycastPlugin = async (rawOptions: ConvertOptions): Promise<ConversionReport> => {
33
71
  const options = resolveConvertOptions(rawOptions);
34
72
  const warnings: ConvertWarning[] = [];
35
73
  const sourcePackage = await readJson<RaycastPackage>(path.join(options.inputDir, 'package.json'));
74
+ const convertedPackageName = resolveConvertedPackageName(sourcePackage, options.inputDir);
36
75
  const sourceCommands = sourcePackage.commands || [];
37
- const { convertedCommands, skippedCommands } = await resolveNoViewCommands(options.inputDir, sourceCommands);
76
+ const { convertedCommands, skippedCommands } = await resolveSupportedCommands(options.inputDir, sourceCommands);
77
+
78
+ const noViewCommands = convertedCommands.filter(command => command.mode === 'no-view');
79
+ const viewCommands = convertedCommands.filter(command => command.mode === 'view');
80
+ const hasViewCommands = viewCommands.length > 0;
81
+ const viewHtmlUsesDevServer = hasViewCommands && options.mode === 'development';
38
82
 
39
83
  await fs.rm(options.outputDir, { recursive: true, force: true });
84
+ await copyPluginSourceToOutput(options.inputDir, options.outputDir);
40
85
  await fs.mkdir(options.buildDir, { recursive: true });
41
86
  await fs.mkdir(options.distDir, { recursive: true });
42
- await copyAssetsDir(options.inputDir, options.assetsDir);
43
87
 
44
88
  const icon = normalizeRaycastIcon(sourcePackage.icon || convertedCommands[0]?.icon) || DEFAULT_PLUGIN_ICON;
45
89
  const commandPreferences = convertedCommands.flatMap(command => command.preferences || []);
46
90
  const preferences = mergePreferences(sourcePackage.preferences || [], commandPreferences, warnings);
47
91
  const publicCommands = createPublicCommands(convertedCommands, icon);
48
92
  const publicPlugin = {
49
- title: sourcePackage.title || sourcePackage.name,
50
- subtitle: sourcePackage.description || sourcePackage.name,
93
+ title: sourcePackage.title || sourcePackage.name || convertedPackageName,
94
+ subtitle: sourcePackage.description || sourcePackage.name || convertedPackageName,
51
95
  description: sourcePackage.description,
52
96
  icon,
53
- main: './dist/public-main.js',
97
+ ...(noViewCommands.length ? { main: './dist/public-main.js' } : {}),
54
98
  server: './dist/server.js',
99
+ ...(viewCommands.length
100
+ ? { html: viewHtmlUsesDevServer ? RAYCAST_VIEW_TEMPLATE_DEV_ENTRY : './dist/view/raycast.html' }
101
+ : {}),
55
102
  ...(preferences.length ? { preferences } : {}),
56
103
  commands: publicCommands,
57
104
  };
58
105
 
59
106
  await writeJson(path.join(options.outputDir, 'package.json'), createConvertedPackage(sourcePackage, publicPlugin, {
107
+ convertedPackageName,
60
108
  publicApiDependency: options.publicApiDependency,
61
109
  warnings,
110
+ hasViewCommands,
62
111
  }));
63
- await fs.writeFile(path.join(options.buildDir, 'public-main.ts'), generatePublicMain(), 'utf8');
64
- await fs.writeFile(path.join(options.buildDir, 'server.ts'), generateServerModule(convertedCommands, sourcePackage.name, publicCommands), 'utf8');
65
- await fs.writeFile(path.join(options.outputDir, 'tsdown.config.ts'), generateTsdownConfig(options), 'utf8');
112
+
113
+ if (noViewCommands.length) {
114
+ await fs.writeFile(path.join(options.buildDir, 'public-main.ts'), generatePublicMain(), 'utf8');
115
+ }
116
+ await fs.writeFile(
117
+ path.join(options.buildDir, 'server.ts'),
118
+ generateServerModule(
119
+ { noView: noViewCommands, view: viewCommands },
120
+ convertedPackageName,
121
+ publicCommands,
122
+ {
123
+ inputDir: options.inputDir,
124
+ outputDir: options.outputDir,
125
+ buildDir: options.buildDir,
126
+ },
127
+ ),
128
+ 'utf8',
129
+ );
130
+
131
+ let raycastViewRepoRoot: string | null = null;
132
+ if (hasViewCommands) {
133
+ copyRaycastWorkerViewBundle(options.buildDir);
134
+ if (viewHtmlUsesDevServer) {
135
+ raycastViewRepoRoot = null;
136
+ } else {
137
+ raycastViewRepoRoot = findPublicTauriRepoRoot(options.invocationDir)
138
+ ?? findPublicTauriRepoRoot(process.cwd());
139
+ if (!raycastViewRepoRoot) {
140
+ throw new Error('Could not locate Public Tauri repo root (pnpm-workspace.yaml + packages/api). '
141
+ + 'Run raycast-convert from the monorepo, set invocationDir, or copy packages/template dist into dist/view manually.');
142
+ }
143
+ ensureRaycastViewTemplateBuilt(raycastViewRepoRoot);
144
+ if (!options.build) {
145
+ copyRaycastViewTemplateAppDist(raycastViewRepoRoot, options.distDir);
146
+ }
147
+ }
148
+ }
149
+
150
+ await fs.writeFile(path.join(options.outputDir, 'tsdown.config.ts'), generateTsdownConfig(options, {
151
+ hasPublicMain: noViewCommands.length > 0,
152
+ }), 'utf8');
66
153
 
67
154
  const report: ConversionReport = {
68
155
  source: options.inputDir,
69
156
  output: options.outputDir,
70
- convertedCommands: convertedCommands.map(command => ({ name: command.name, entry: command.entry })),
157
+ sourcePackageName: typeof sourcePackage.name === 'string' ? sourcePackage.name : undefined,
158
+ convertedPackageName,
159
+ convertedCommands: convertedCommands.map((command) => {
160
+ const outputEntry = path.join(options.outputDir, path.relative(path.resolve(options.inputDir), path.resolve(command.entry)));
161
+ const entry = path.relative(options.outputDir, outputEntry).split(path.sep)
162
+ .join('/') || '.';
163
+ return { name: command.name, entry };
164
+ }),
71
165
  skippedCommands,
72
166
  warnings,
73
167
  };
@@ -75,6 +169,10 @@ export const convertRaycastPlugin = async (rawOptions: ConvertOptions): Promise<
75
169
 
76
170
  if (options.build) {
77
171
  installAndBuild(options);
172
+ // tsdown 会清空 `dist/`;view 子应用在 tsdown 之后重新拷贝
173
+ if (hasViewCommands && raycastViewRepoRoot) {
174
+ copyRaycastViewTemplateAppDist(raycastViewRepoRoot, options.distDir);
175
+ }
78
176
  }
79
177
 
80
178
  return report;
package/src/options.ts CHANGED
@@ -1,12 +1,46 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import type { ConvertMode, ConvertOptions, ResolvedConvertOptions } from './types';
3
4
 
4
5
  const resolveMode = (mode: ConvertOptions['mode']): ConvertMode => mode || 'development';
5
6
 
7
+ /**
8
+ * 自 startDir 向上查找 monorepo 根(pnpm-workspace.yaml + packages/api),避免依赖 process.cwd()
9
+ *(例如从 src-node、src-node/src 启动 Node 时 cwd 并非仓库根)。
10
+ */
11
+ const findPublicTauriRepoRoot = (startDir: string): string | undefined => {
12
+ let dir = path.resolve(startDir);
13
+ const { root } = path.parse(dir);
14
+ while (dir !== root) {
15
+ const ws = path.join(dir, 'pnpm-workspace.yaml');
16
+ const apiPkg = path.join(dir, 'packages', 'api', 'package.json');
17
+ if (fs.existsSync(ws) && fs.existsSync(apiPkg)) {
18
+ return dir;
19
+ }
20
+ dir = path.dirname(dir);
21
+ }
22
+ return undefined;
23
+ };
24
+
25
+ const resolveInvocationDir = (options: ConvertOptions): string => {
26
+ if (options.invocationDir) {
27
+ return path.resolve(options.invocationDir);
28
+ }
29
+ const mode = resolveMode(options.mode);
30
+ if (mode !== 'development') {
31
+ return path.resolve(process.cwd());
32
+ }
33
+ const fromRoot = findPublicTauriRepoRoot(process.cwd());
34
+ if (fromRoot) {
35
+ return fromRoot;
36
+ }
37
+ throw new Error('development 模式需要定位 monorepo 根目录(含 pnpm-workspace.yaml 与 packages/api)。请在仓库内执行、设置 invocationDir,或改用 production 模式。');
38
+ };
39
+
6
40
  export const resolveConvertOptions = (options: ConvertOptions): ResolvedConvertOptions => {
7
41
  const inputDir = path.resolve(options.inputDir);
8
42
  const outputDir = path.resolve(options.outputDir || `${inputDir}-public`);
9
- const invocationDir = path.resolve(options.invocationDir || process.cwd());
43
+ const invocationDir = resolveInvocationDir(options);
10
44
  const mode = resolveMode(options.mode);
11
45
 
12
46
  return {
@@ -18,6 +52,5 @@ export const resolveConvertOptions = (options: ConvertOptions): ResolvedConvertO
18
52
  publicApiDependency: mode === 'development' ? `file:${path.join(invocationDir, 'packages', 'api')}` : 'latest',
19
53
  buildDir: path.join(outputDir, '.raycast-build'),
20
54
  distDir: path.join(outputDir, 'dist'),
21
- assetsDir: path.join(outputDir, 'assets'),
22
55
  };
23
56
  };
@@ -1,15 +1,10 @@
1
1
  import type { ConvertWarning, RaycastPackage } from './types';
2
2
 
3
- const raycastApiPackages = new Set(['@raycast/api', '@raycast/utils']);
4
-
5
3
  const rewriteDependencyMap = (dependencies: Record<string, string> | undefined) => {
6
4
  const rewritten = { ...(dependencies || {}) };
7
- let replacedRaycastApi = false;
8
- for (const packageName of raycastApiPackages) {
9
- if (packageName in rewritten) {
10
- delete rewritten[packageName];
11
- replacedRaycastApi = true;
12
- }
5
+ const replacedRaycastApi = '@raycast/api' in rewritten;
6
+ if (replacedRaycastApi) {
7
+ delete rewritten['@raycast/api'];
13
8
  }
14
9
  return { dependencies: rewritten, replacedRaycastApi };
15
10
  };
@@ -17,19 +12,25 @@ const rewriteDependencyMap = (dependencies: Record<string, string> | undefined)
17
12
  export const createConvertedPackage = (
18
13
  sourcePackage: RaycastPackage,
19
14
  publicPlugin: Record<string, unknown>,
20
- options: { publicApiDependency: string, warnings: ConvertWarning[] },
15
+ options: {
16
+ convertedPackageName: string,
17
+ publicApiDependency: string,
18
+ warnings: ConvertWarning[],
19
+ hasViewCommands?: boolean,
20
+ },
21
21
  ) => {
22
22
  const dependenciesResult = rewriteDependencyMap(sourcePackage.dependencies);
23
23
  const devDependenciesResult = rewriteDependencyMap(sourcePackage.devDependencies);
24
24
  if (dependenciesResult.replacedRaycastApi || devDependenciesResult.replacedRaycastApi) {
25
25
  options.warnings.push({
26
26
  type: 'dependency',
27
- message: 'Replaced @raycast/api and/or @raycast/utils with @public-tauri/api',
27
+ message: 'Replaced @raycast/api with @public-tauri/api (see tsdown alias); @raycast/utils is left as declared',
28
28
  });
29
29
  }
30
30
 
31
31
  return {
32
32
  ...sourcePackage,
33
+ name: options.convertedPackageName,
33
34
  version: sourcePackage.version || '1.0.0',
34
35
  type: 'module',
35
36
  private: true,
@@ -41,10 +42,17 @@ export const createConvertedPackage = (
41
42
  dependencies: {
42
43
  ...dependenciesResult.dependencies,
43
44
  '@public-tauri/api': dependenciesResult.dependencies['@public-tauri/api'] || options.publicApiDependency,
45
+ ...(options.hasViewCommands ? {
46
+ react: dependenciesResult.dependencies.react || '^19.0.0',
47
+ 'react-reconciler': dependenciesResult.dependencies['react-reconciler'] || '^0.31.0',
48
+ } : {}),
44
49
  },
45
50
  devDependencies: {
46
51
  ...devDependenciesResult.dependencies,
47
52
  tsdown: devDependenciesResult.dependencies.tsdown || '^0.21.7',
53
+ ...(options.hasViewCommands ? {
54
+ '@types/react': devDependenciesResult.dependencies['@types/react'] || '^19.0.0',
55
+ } : {}),
48
56
  },
49
57
  };
50
58
  };
@@ -0,0 +1,40 @@
1
+ import path from 'node:path';
2
+ import type { RaycastPackage } from './types';
3
+
4
+ export const RAYCAST_CONVERTED_SCOPE = '@public-tauri-raycast';
5
+
6
+ /** npm 包名片段:与 npm 命名惯例对齐的小写、符号规整 */
7
+ export const sanitizeSlug = (raw: string): string => {
8
+ let segment = raw.trim().toLowerCase()
9
+ .replace(/[^a-z0-9._-]/g, '-')
10
+ .replace(/-+/g, '-')
11
+ .replace(/^-+|-+$/g, '');
12
+ if (!segment || /^[._]/.test(segment)) {
13
+ segment = `plugin-${segment || 'unnamed'}`.replace(/^-+/, '');
14
+ }
15
+ return segment || 'raycast-plugin';
16
+ };
17
+
18
+ /**
19
+ * 未 scope 的 Raycast 官方插件:一般为单段名字 → `@public-tauri-raycast/<slug>`。
20
+ * 若将来出现 scoped:`@my-scope/xxx` → `@public-tauri-raycast/my-scope_xxx`。
21
+ */
22
+ export const resolveRaycastSlug = (sourcePackage: RaycastPackage, inputDir: string): string => {
23
+ const raw = typeof sourcePackage.name === 'string' ? sourcePackage.name.trim() : '';
24
+ if (raw.startsWith('@')) {
25
+ const slash = raw.indexOf('/');
26
+ if (slash !== -1) {
27
+ const scope = raw.slice(1, slash);
28
+ const pkg = raw.slice(slash + 1);
29
+ return `${sanitizeSlug(scope)}_${sanitizeSlug(pkg)}`;
30
+ }
31
+ return sanitizeSlug(raw.slice(1));
32
+ }
33
+ if (raw) {
34
+ return sanitizeSlug(raw);
35
+ }
36
+ return sanitizeSlug(path.basename(path.resolve(inputDir)));
37
+ };
38
+
39
+ export const resolveConvertedPackageName = (sourcePackage: RaycastPackage, inputDir: string): string =>
40
+ `${RAYCAST_CONVERTED_SCOPE}/${resolveRaycastSlug(sourcePackage, inputDir)}`;
package/src/types.ts CHANGED
@@ -11,6 +11,21 @@ export type RaycastPreference = {
11
11
  data?: { title?: string, label?: string, value: string | number | boolean }[];
12
12
  };
13
13
 
14
+ export type RaycastCommandArgumentType = 'text' | 'password' | 'dropdown';
15
+
16
+ export type RaycastCommandArgumentOption = {
17
+ title?: string;
18
+ value: string;
19
+ };
20
+
21
+ export type RaycastCommandArgument = {
22
+ name: string;
23
+ placeholder?: string;
24
+ type: RaycastCommandArgumentType;
25
+ required?: boolean;
26
+ data?: RaycastCommandArgumentOption[];
27
+ };
28
+
14
29
  export type RaycastCommand = {
15
30
  name: string;
16
31
  title?: string;
@@ -20,10 +35,11 @@ export type RaycastCommand = {
20
35
  keywords?: string[];
21
36
  icon?: string;
22
37
  preferences?: RaycastPreference[];
38
+ arguments?: RaycastCommandArgument[];
23
39
  };
24
40
 
25
41
  export type RaycastPackage = {
26
- name: string;
42
+ name?: string;
27
43
  version?: string;
28
44
  type?: string;
29
45
  title?: string;
@@ -41,8 +57,11 @@ export type ConvertWarning = {
41
57
  message: string;
42
58
  };
43
59
 
60
+ export type SupportedRaycastCommandMode = 'no-view' | 'view';
61
+
44
62
  export type ConvertedCommand = RaycastCommand & {
45
63
  entry: string;
64
+ mode: SupportedRaycastCommandMode;
46
65
  };
47
66
 
48
67
  export type ConvertMode = 'development' | 'production';
@@ -61,12 +80,15 @@ export type ResolvedConvertOptions = Required<Omit<ConvertOptions, 'outputDir' |
61
80
  publicApiDependency: string;
62
81
  buildDir: string;
63
82
  distDir: string;
64
- assetsDir: string;
65
83
  };
66
84
 
67
85
  export type ConversionReport = {
68
86
  source: string;
69
87
  output: string;
88
+ /** Raycast extension package.json name before conversion (may be missing). */
89
+ sourcePackageName?: string;
90
+ /** Converted Public plugin npm name, e.g. @public-tauri-raycast/screenshot */
91
+ convertedPackageName: string;
70
92
  convertedCommands: { name: string, entry: string }[];
71
93
  skippedCommands: { name: string, reason: string }[];
72
94
  warnings: ConvertWarning[];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Custom reconciler 内部的宿主节点(与 React fiber 解耦后的最小树)。
3
+ * 序列化层直接遍历该结构生成可 JSON 化的 VirtualNode。
4
+ */
5
+
6
+ export type HostTextInstance = {
7
+ type: 'text';
8
+ /** 宿主分配的不透明 id,序列化与事件派发与 RN reactTag 同类 */
9
+ hostId: string;
10
+ text: string;
11
+ parent: HostElementInstance | HostRootContainer | null;
12
+ };
13
+
14
+ export type HostElementInstance = {
15
+ type: string;
16
+ hostId: string;
17
+ props: Record<string, unknown>;
18
+ parent: HostElementInstance | HostRootContainer | null;
19
+ children: HostInstance[];
20
+ };
21
+
22
+ /** createContainer 的根容器,仅含 children */
23
+ export type HostRootContainer = {
24
+ children: HostInstance[];
25
+ };
26
+
27
+ export type HostInstance = HostTextInstance | HostElementInstance;
28
+
29
+ export function isHostText(n: HostInstance): n is HostTextInstance {
30
+ return n.type === 'text';
31
+ }
32
+
33
+ export function isHostElement(n: HostInstance): n is HostElementInstance {
34
+ return n.type !== 'text';
35
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Minimal JSON Patch (RFC 6902) diff generator.
3
+ *
4
+ * Tailored for the Raycast view snapshot tree:
5
+ * - Objects with a `hostId` field in arrays are matched by `hostId` for stable diffing.
6
+ * - Arrays without `hostId` items are compared index-by-index.
7
+ */
8
+
9
+ export type JsonPatchOp =
10
+ | { op: 'add'; path: string; value: unknown }
11
+ | { op: 'remove'; path: string }
12
+ | { op: 'replace'; path: string; value: unknown };
13
+
14
+ function escapePointer(s: string | number): string {
15
+ return String(s).replace(/~/g, '~0')
16
+ .replace(/\//g, '~1');
17
+ }
18
+
19
+ function isObject(v: unknown): v is Record<string, unknown> {
20
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
21
+ }
22
+
23
+ function hasHostId(v: unknown): v is { hostId: string;[k: string]: unknown } {
24
+ return isObject(v) && typeof v.hostId === 'string';
25
+ }
26
+
27
+ function diffValue(oldVal: unknown, newVal: unknown, path: string, ops: JsonPatchOp[]): void {
28
+ if (oldVal === newVal) return;
29
+
30
+ if (oldVal === null || oldVal === undefined || newVal === null || newVal === undefined) {
31
+ if (oldVal !== newVal) ops.push({ op: 'replace', path, value: newVal });
32
+ return;
33
+ }
34
+
35
+ if (typeof oldVal !== typeof newVal) {
36
+ ops.push({ op: 'replace', path, value: newVal });
37
+ return;
38
+ }
39
+
40
+ if (typeof oldVal !== 'object') {
41
+ if (oldVal !== newVal) ops.push({ op: 'replace', path, value: newVal });
42
+ return;
43
+ }
44
+
45
+ const isOldArr = Array.isArray(oldVal);
46
+ const isNewArr = Array.isArray(newVal);
47
+ if (isOldArr !== isNewArr) {
48
+ ops.push({ op: 'replace', path, value: newVal });
49
+ return;
50
+ }
51
+
52
+ if (isOldArr && isNewArr) {
53
+ diffArray(oldVal as unknown[], newVal as unknown[], path, ops);
54
+ return;
55
+ }
56
+
57
+ diffObject(
58
+ oldVal as Record<string, unknown>,
59
+ newVal as Record<string, unknown>,
60
+ path,
61
+ ops,
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Diff two arrays. If both contain objects with `hostId`, use hostId-based
67
+ * matching so that insertions/deletions don't cause cascading replace ops on
68
+ * every subsequent element.
69
+ */
70
+ function diffArray(oldArr: unknown[], newArr: unknown[], path: string, ops: JsonPatchOp[]): void {
71
+ const allOldHostId = oldArr.length > 0 && oldArr.every(hasHostId);
72
+ const allNewHostId = newArr.length > 0 && newArr.every(hasHostId);
73
+
74
+ if (allOldHostId && allNewHostId) {
75
+ diffHostIdArray(oldArr as { hostId: string;[k: string]: unknown }[], newArr as { hostId: string;[k: string]: unknown }[], path, ops);
76
+ return;
77
+ }
78
+
79
+ diffIndexArray(oldArr, newArr, path, ops);
80
+ }
81
+
82
+ function diffIndexArray(oldArr: unknown[], newArr: unknown[], path: string, ops: JsonPatchOp[]): void {
83
+ const minLen = Math.min(oldArr.length, newArr.length);
84
+ for (let i = 0; i < minLen; i++) {
85
+ diffValue(oldArr[i], newArr[i], `${path}/${i}`, ops);
86
+ }
87
+ for (let i = minLen; i < newArr.length; i++) {
88
+ ops.push({ op: 'add', path: `${path}/-`, value: newArr[i] });
89
+ }
90
+ for (let i = oldArr.length - 1; i >= newArr.length; i--) {
91
+ ops.push({ op: 'remove', path: `${path}/${i}` });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Diff arrays whose elements each have a `hostId`.
97
+ * Match by hostId so re-ordering or insertions produce minimal patches.
98
+ * If the order of shared hostIds diverges, fall back to a wholesale replace.
99
+ */
100
+ function diffHostIdArray(
101
+ oldArr: { hostId: string;[k: string]: unknown }[],
102
+ newArr: { hostId: string;[k: string]: unknown }[],
103
+ path: string,
104
+ ops: JsonPatchOp[],
105
+ ): void {
106
+ const oldMap = new Map(oldArr.map((item, i) => [item.hostId, { item, index: i }]));
107
+ const newMap = new Map(newArr.map((item, i) => [item.hostId, { item, index: i }]));
108
+
109
+ const sharedOldOrder = oldArr.filter(o => newMap.has(o.hostId)).map(o => o.hostId);
110
+ const sharedNewOrder = newArr.filter(n => oldMap.has(n.hostId)).map(n => n.hostId);
111
+
112
+ const orderChanged = sharedOldOrder.length !== sharedNewOrder.length
113
+ || sharedOldOrder.some((id, i) => id !== sharedNewOrder[i]);
114
+
115
+ if (orderChanged) {
116
+ ops.push({ op: 'replace', path, value: newArr });
117
+ return;
118
+ }
119
+
120
+ const removedIds = oldArr.filter(o => !newMap.has(o.hostId)).map(o => o.hostId);
121
+ for (let i = oldArr.length - 1; i >= 0; i--) {
122
+ if (removedIds.includes(oldArr[i].hostId)) {
123
+ ops.push({ op: 'remove', path: `${path}/${i}` });
124
+ }
125
+ }
126
+
127
+ const kept = oldArr.filter(o => newMap.has(o.hostId));
128
+ for (const oldItem of kept) {
129
+ const newEntry = newMap.get(oldItem.hostId)!;
130
+ diffValue(oldItem, newEntry.item, `${path}/${newEntry.index}`, ops);
131
+ }
132
+
133
+ const addedEntries = newArr
134
+ .map((item, i) => ({ item, index: i }))
135
+ .filter(e => !oldMap.has(e.item.hostId));
136
+ for (const entry of addedEntries) {
137
+ ops.push({ op: 'add', path: `${path}/${entry.index}`, value: entry.item });
138
+ }
139
+ }
140
+
141
+ function diffObject(
142
+ oldObj: Record<string, unknown>,
143
+ newObj: Record<string, unknown>,
144
+ path: string,
145
+ ops: JsonPatchOp[],
146
+ ): void {
147
+ for (const key of Object.keys(oldObj)) {
148
+ if (!(key in newObj)) {
149
+ ops.push({ op: 'remove', path: `${path}/${escapePointer(key)}` });
150
+ }
151
+ }
152
+ for (const key of Object.keys(newObj)) {
153
+ const p = `${path}/${escapePointer(key)}`;
154
+ if (!(key in oldObj)) {
155
+ ops.push({ op: 'add', path: p, value: newObj[key] });
156
+ } else {
157
+ diffValue(oldObj[key], newObj[key], p, ops);
158
+ }
159
+ }
160
+ }
161
+
162
+ export function generateJsonPatch(oldVal: unknown, newVal: unknown): JsonPatchOp[] {
163
+ const ops: JsonPatchOp[] = [];
164
+ diffValue(oldVal, newVal, '', ops);
165
+ return ops;
166
+ }
@@ -0,0 +1,43 @@
1
+ /** Worker 下发给视图端的宿主树快照(JSON-safe)。 */
2
+
3
+ /**
4
+ * 函数型 prop 在快照中的占位前缀;完整 token 为 `${RAYCAST_SERIALIZED_FUNC_PREFIX}${propName}`(propName 即 Worker Registry 事件名,如 `onAction`)。
5
+ * 视图端应反序列化为可调函数并 `channel.invoke('raycast:view:run-action', { commandName, hostId, event: propName, args })`。
6
+ */
7
+ export const RAYCAST_SERIALIZED_FUNC_PREFIX = '__func__';
8
+
9
+ export type { JsonPatchOp } from './json-patch';
10
+
11
+ export type SerializedHostElementNode = {
12
+ hostId: string;
13
+ type: string;
14
+ props: Record<string, unknown>;
15
+ children: SerializedHostNode[];
16
+ };
17
+
18
+ export type SerializedHostNode =
19
+ | { hostId: string; type: 'text'; text: string }
20
+ | SerializedHostElementNode;
21
+
22
+ export type RaycastViewSnapshot = {
23
+ commandName: string;
24
+ root: SerializedHostNode;
25
+ error?: string;
26
+ };
27
+
28
+ export type RaycastViewMountPayload = {
29
+ commandName: string;
30
+ query?: string;
31
+ preferences?: Record<string, unknown>;
32
+ options?: Record<string, unknown>;
33
+ };
34
+
35
+ /** run-action 通道:hostId 为 SerializedHostNode.hostId(含 props slot 内合成的 rv:p:*) */
36
+ export type RaycastViewDispatchPayload = {
37
+ commandName: string;
38
+ hostId: string;
39
+ /** 缺省为 onAction;与序列化 token 中 prop 名一致 */
40
+ event?: string;
41
+ /** 透传给 Worker 端 handler(`handlers.get(hostId + ':' + event)`) */
42
+ args?: unknown[];
43
+ };