@plumeai/plugin-sdk 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/bin/plume-plugin.mjs +70 -0
- package/dist/client/styles/src/index.d.ts +123 -0
- package/dist/client/ui/src/avatar/AgentAvatar.d.ts +42 -0
- package/dist/client/ui/src/avatar/AvatarPile.d.ts +36 -0
- package/dist/client/ui/src/avatar/AvatarStack.d.ts +26 -0
- package/dist/client/ui/src/avatar/art.d.ts +32 -0
- package/dist/client/ui/src/fileIcon.d.ts +33 -0
- package/dist/client/ui/src/hooks/useCopied.d.ts +16 -0
- package/dist/client/ui/src/hooks/useLiveRefresh.d.ts +22 -0
- package/dist/client/ui/src/hooks/useNarrow.d.ts +16 -0
- package/dist/client/ui/src/i18n/context.d.ts +17 -0
- package/dist/client/ui/src/i18n/index.d.ts +2 -0
- package/dist/client/ui/src/i18n/list.d.ts +20 -0
- package/dist/client/ui/src/i18n/locales.d.ts +10 -0
- package/dist/client/ui/src/i18n/messages/en.d.ts +1418 -0
- package/dist/client/ui/src/i18n/messages/index.d.ts +6 -0
- package/dist/client/ui/src/i18n/messages/zh-CN.d.ts +1263 -0
- package/dist/client/ui/src/i18n/plural.d.ts +16 -0
- package/dist/client/ui/src/i18n/translate.d.ts +43 -0
- package/dist/client/ui/src/icons/index.d.ts +5 -0
- package/dist/client/ui/src/image/AnnotateCanvas.d.ts +29 -0
- package/dist/client/ui/src/image/AnnotateToolbar.d.ts +73 -0
- package/dist/client/ui/src/image/Annotator.d.ts +154 -0
- package/dist/client/ui/src/image/annotate.d.ts +227 -0
- package/dist/client/ui/src/image/paint.d.ts +35 -0
- package/dist/client/ui/src/image/screenshot-geometry.d.ts +112 -0
- package/dist/client/ui/src/image/useBarDrag.d.ts +31 -0
- package/dist/client/ui/src/image/useMovableBar.d.ts +26 -0
- package/dist/client/ui/src/image/useToolbarPlacement.d.ts +42 -0
- package/dist/client/ui/src/index.d.ts +140 -0
- package/dist/client/ui/src/inputs/ColorField.d.ts +9 -0
- package/dist/client/ui/src/inputs/ComposerToggle.d.ts +23 -0
- package/dist/client/ui/src/inputs/DateRangeField.d.ts +25 -0
- package/dist/client/ui/src/inputs/NativeField.d.ts +5 -0
- package/dist/client/ui/src/inputs/SearchField.d.ts +21 -0
- package/dist/client/ui/src/inputs/Segmented.d.ts +23 -0
- package/dist/client/ui/src/inputs/Select.d.ts +32 -0
- package/dist/client/ui/src/inputs/ShortcutRecorder.d.ts +8 -0
- package/dist/client/ui/src/inputs/Slider.d.ts +13 -0
- package/dist/client/ui/src/inputs/TextArea.d.ts +30 -0
- package/dist/client/ui/src/inputs/Toggle.d.ts +10 -0
- package/dist/client/ui/src/inputs/WorktreeToggle.d.ts +19 -0
- package/dist/client/ui/src/inputs/day-range.d.ts +57 -0
- package/dist/client/ui/src/inputs/pickers.d.ts +40 -0
- package/dist/client/ui/src/inputs/useFieldFade.d.ts +1 -0
- package/dist/client/ui/src/keyboard.d.ts +21 -0
- package/dist/client/ui/src/layout/ApprovalFrame.d.ts +33 -0
- package/dist/client/ui/src/layout/Collapse.d.ts +9 -0
- package/dist/client/ui/src/layout/CopyMark.d.ts +19 -0
- package/dist/client/ui/src/layout/Disclosure.d.ts +66 -0
- package/dist/client/ui/src/layout/ErrorBoundary.d.ts +26 -0
- package/dist/client/ui/src/layout/PageTopRow.d.ts +19 -0
- package/dist/client/ui/src/layout/PanelEmpty.d.ts +17 -0
- package/dist/client/ui/src/layout/ResizeHandle.d.ts +24 -0
- package/dist/client/ui/src/layout/RetainedViews.d.ts +32 -0
- package/dist/client/ui/src/layout/Settings.d.ts +41 -0
- package/dist/client/ui/src/layout/Splitter.d.ts +25 -0
- package/dist/client/ui/src/lib/agent-avatar.d.ts +58 -0
- package/dist/client/ui/src/lib/code/font-stack.d.ts +12 -0
- package/dist/client/ui/src/lib/code/themes.d.ts +35 -0
- package/dist/client/ui/src/lib/date-format.d.ts +8 -0
- package/dist/client/ui/src/lib/dispatches.d.ts +8 -0
- package/dist/client/ui/src/lib/hour-style.d.ts +13 -0
- package/dist/client/ui/src/lib/number-draft.d.ts +24 -0
- package/dist/client/ui/src/lib/relative-time.d.ts +18 -0
- package/dist/client/ui/src/lib/tiling.d.ts +89 -0
- package/dist/client/ui/src/lib/typing.d.ts +19 -0
- package/dist/client/ui/src/motion/RollWords.d.ts +26 -0
- package/dist/client/ui/src/motion/RollingText.d.ts +43 -0
- package/dist/client/ui/src/motion/TypedText.d.ts +9 -0
- package/dist/client/ui/src/motion/freeze.d.ts +22 -0
- package/dist/client/ui/src/motion/held.d.ts +17 -0
- package/dist/client/ui/src/motion/loaders.d.ts +65 -0
- package/dist/client/ui/src/motion/reduced.d.ts +13 -0
- package/dist/client/ui/src/motion/ticked.d.ts +24 -0
- package/dist/client/ui/src/motion/ticker.d.ts +31 -0
- package/dist/client/ui/src/motion/tokens.d.ts +29 -0
- package/dist/client/ui/src/motion/useNow.d.ts +6 -0
- package/dist/client/ui/src/overlay/Confirm.d.ts +96 -0
- package/dist/client/ui/src/overlay/ContextMenu.d.ts +40 -0
- package/dist/client/ui/src/overlay/Dialog.d.ts +60 -0
- package/dist/client/ui/src/overlay/Menu.d.ts +90 -0
- package/dist/client/ui/src/overlay/Overlay.d.ts +17 -0
- package/dist/client/ui/src/overlay/Popover.d.ts +127 -0
- package/dist/client/ui/src/overlay/Tooltip.d.ts +14 -0
- package/dist/client/ui/src/overlay/afterimage.d.ts +20 -0
- package/dist/client/ui/src/overlay/chrome-top.d.ts +8 -0
- package/dist/client/ui/src/overlay/hover-layers.d.ts +41 -0
- package/dist/client/ui/src/overlay/portal.d.ts +21 -0
- package/dist/client/ui/src/overlay/scrims.d.ts +20 -0
- package/dist/client/ui/src/overlay/tooltip-attribute.d.ts +62 -0
- package/dist/client/ui/src/primitives/Button.d.ts +64 -0
- package/dist/client/ui/src/primitives/Caret.d.ts +25 -0
- package/dist/client/ui/src/primitives/ChoiceMark.d.ts +12 -0
- package/dist/client/ui/src/primitives/ClosableTab.d.ts +23 -0
- package/dist/client/ui/src/primitives/CountUp.d.ts +21 -0
- package/dist/client/ui/src/primitives/GitHubMark.d.ts +12 -0
- package/dist/client/ui/src/primitives/IconButton.d.ts +55 -0
- package/dist/client/ui/src/primitives/MarkNumber.d.ts +12 -0
- package/dist/client/ui/src/primitives/RowDeleteButton.d.ts +6 -0
- package/dist/client/ui/src/primitives/Skeleton.d.ts +36 -0
- package/dist/client/ui/src/primitives/TabStrip.d.ts +29 -0
- package/dist/client/ui/src/primitives/Text.d.ts +341 -0
- package/dist/client/ui/src/primitives/TimeAgo.d.ts +13 -0
- package/dist/client/ui/src/primitives/useCountUp.d.ts +23 -0
- package/dist/client/ui/src/row/FlowRow.d.ts +31 -0
- package/dist/client/ui/src/row/HoverRow.d.ts +28 -0
- package/dist/client/ui/src/scroll/BackToLatest.d.ts +19 -0
- package/dist/client/ui/src/scroll/OverlayScrollbar.d.ts +9 -0
- package/dist/client/ui/src/scroll/ScrollText.d.ts +24 -0
- package/dist/client/ui/src/scroll/Scroller.d.ts +66 -0
- package/dist/client/ui/src/scroll/Sideways.d.ts +30 -0
- package/dist/client/ui/src/scroll/fade.d.ts +2 -0
- package/dist/client/ui/src/scroll/follow.d.ts +157 -0
- package/dist/client/ui/src/scroll/measure-batch.d.ts +13 -0
- package/dist/client/ui/src/scroll/memory.d.ts +22 -0
- package/dist/client/ui/src/scroll/signature.d.ts +24 -0
- package/dist/client/ui/src/scroll/useFollowBottom.d.ts +80 -0
- package/dist/client/ui/src/scroll/useSideways.d.ts +29 -0
- package/dist/contract/src/app.d.ts +16 -0
- package/dist/contract/src/changes.d.ts +50 -0
- package/dist/contract/src/diagnostics.d.ts +86 -0
- package/dist/contract/src/index.d.ts +18 -0
- package/dist/contract/src/plugin.d.ts +1391 -0
- package/dist/contract/src/resources.d.ts +79 -0
- package/dist/contract/src/service.d.ts +806 -0
- package/dist/contract/src/ui.d.ts +1039 -0
- package/dist/contract/src/usage.d.ts +102 -0
- package/dist/core/llm/ai/anthropic-messages-request.d.ts +82 -0
- package/dist/core/llm/ai/anthropic-messages.d.ts +43 -0
- package/dist/core/llm/ai/cache-routing.d.ts +53 -0
- package/dist/core/llm/ai/chat-delta.d.ts +107 -0
- package/dist/core/llm/ai/compat-key.d.ts +19 -0
- package/dist/core/llm/ai/endpoint.d.ts +57 -0
- package/dist/core/llm/ai/failure.d.ts +181 -0
- package/dist/core/llm/ai/index.d.ts +16 -0
- package/dist/core/llm/ai/openai-chat-completions-request.d.ts +49 -0
- package/dist/core/llm/ai/openai-chat-completions.d.ts +37 -0
- package/dist/core/llm/ai/openai-responses-request.d.ts +63 -0
- package/dist/core/llm/ai/openai-responses.d.ts +11 -0
- package/dist/core/llm/ai/prefix-fingerprint.d.ts +20 -0
- package/dist/core/llm/ai/reasoning-compat.d.ts +69 -0
- package/dist/core/llm/ai/request-params-compat.d.ts +39 -0
- package/dist/core/llm/ai/retry.d.ts +130 -0
- package/dist/core/llm/ai/sanitize-history.d.ts +23 -0
- package/dist/core/llm/ai/think-tag.d.ts +37 -0
- package/dist/core/llm/ai/thinking-options.d.ts +35 -0
- package/dist/core/llm/ai/tool-names.d.ts +17 -0
- package/dist/core/llm/ai/tool-pairing-compat.d.ts +53 -0
- package/dist/core/llm/ai/tool-result-images-compat.d.ts +24 -0
- package/dist/core/llm/ai/tool-schema.d.ts +20 -0
- package/dist/core/llm/ai/usage-fields.d.ts +30 -0
- package/dist/core/llm/ai/vendors/index.d.ts +20 -0
- package/dist/core/llm/ai/vendors/thinking.d.ts +39 -0
- package/dist/core/llm/ai/vendors/types.d.ts +47 -0
- package/dist/core/llm/config/image-models.d.ts +34 -0
- package/dist/core/llm/config/retry-policy.d.ts +35 -0
- package/dist/core/llm/images/chat-images.d.ts +11 -0
- package/dist/core/llm/images/dimensions.d.ts +15 -0
- package/dist/core/llm/images/generate.d.ts +15 -0
- package/dist/core/llm/images/openai-images.d.ts +11 -0
- package/dist/core/llm/images/quota.d.ts +9 -0
- package/dist/core/llm/images/shape.d.ts +44 -0
- package/dist/core/llm/images/types.d.ts +118 -0
- package/dist/core/llm/model-catalog-format.d.ts +50 -0
- package/dist/core/llm/model-catalog.d.ts +51 -0
- package/dist/core/llm/types/message.d.ts +450 -0
- package/dist/core/llm/types/provider.d.ts +169 -0
- package/dist/core/llm/types/tool.d.ts +23 -0
- package/dist/core/llm/types.d.ts +10 -0
- package/dist/core/llm/utils/pricing.d.ts +18 -0
- package/dist/core/llm/utils/record.d.ts +2 -0
- package/dist/core/llm/utils/sse.d.ts +69 -0
- package/dist/infra/rpc/src/endpoint.d.ts +36 -0
- package/dist/infra/rpc/src/index.d.ts +2 -0
- package/dist/infra/rpc/src/rpc.d.ts +54 -0
- package/dist/theme.css +336 -0
- package/dist/ui.d.ts +2 -0
- package/lib/build.mjs +143 -0
- package/lib/check.mjs +23 -0
- package/lib/dev.mjs +52 -0
- package/lib/icons.mjs +2 -0
- package/lib/init.mjs +109 -0
- package/lib/install.mjs +80 -0
- package/lib/link.mjs +44 -0
- package/lib/lint/i18n.mjs +149 -0
- package/lib/lint/imports.mjs +83 -0
- package/lib/lint/index.mjs +54 -0
- package/lib/lint/styles.mjs +160 -0
- package/lib/manifest.d.mts +9 -0
- package/lib/manifest.mjs +99 -0
- package/lib/targets.mjs +60 -0
- package/package.json +34 -4
- package/skill/SKILL.md +150 -0
- package/skill/references/components.md +2830 -0
- package/skill/references/logic.md +526 -0
- package/skill/references/style-i18n.md +70 -0
- package/skill/references/ui.md +532 -0
- package/template/AGENTS.md +17 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +12 -0
- package/template/gitignore +2 -0
- package/template/src/index.ts +28 -0
- package/template/src/messages.ts +16 -0
- package/template/src/types.ts +8 -0
- package/template/src/ui.tsx +30 -0
- package/template/tsconfig.json +7 -0
- package/tsconfig.json +25 -0
- package/README.md +0 -3
package/lib/check.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 检查一个插件:先用 SDK 自带的 TypeScript 做类型检查(插件作者的机器上不用另装),再跑插件规则(lint/)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { formatProblem, lintPlugin } from "./lint/index.mjs";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} target 插件目录(有 tsconfig.json)
|
|
14
|
+
* @returns {Promise<boolean>} 通过没有;报错直接打到终端
|
|
15
|
+
*/
|
|
16
|
+
export async function checkPlugin(target) {
|
|
17
|
+
const dir = resolve(target);
|
|
18
|
+
const tsc = join(dirname(require.resolve("typescript/package.json")), "bin/tsc");
|
|
19
|
+
const result = spawnSync(process.execPath, [tsc, "-p", join(dir, "tsconfig.json")], { stdio: "inherit" });
|
|
20
|
+
const problems = await lintPlugin(dir);
|
|
21
|
+
if (problems.length > 0) console.error(`插件规则没通过:\n\n${problems.map((problem) => ` ${formatProblem(problem, dir)}`).join("\n")}\n`);
|
|
22
|
+
return result.status === 0 && problems.length === 0;
|
|
23
|
+
}
|
package/lib/dev.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开发一个插件:构建、装进 Plume,然后盯着源码,一改就重新构建、重新装。Plume 开着的话每次都当场换上新的。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { watch } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { buildPlugin } from "./build.mjs";
|
|
8
|
+
import { installPlugin } from "./install.mjs";
|
|
9
|
+
|
|
10
|
+
/** 连续保存(编辑器、agent 一次改几个文件)只算一次。 */
|
|
11
|
+
const SETTLE_MS = 200;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} target 插件目录
|
|
15
|
+
* @param {{ root?: string, log?: (line: string) => void }} [options] root:装到哪个目录下
|
|
16
|
+
* @returns {() => void} 停止监视
|
|
17
|
+
*/
|
|
18
|
+
export function devPlugin(target, options = {}) {
|
|
19
|
+
const dir = resolve(target);
|
|
20
|
+
const log = options.log ?? console.log;
|
|
21
|
+
let timer;
|
|
22
|
+
let running = Promise.resolve();
|
|
23
|
+
|
|
24
|
+
const round = () => {
|
|
25
|
+
running = running.then(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const built = await buildPlugin(dir);
|
|
28
|
+
const installed = installPlugin(dir, options.root);
|
|
29
|
+
log(`✓ ${built} → ${installed}`);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// esbuild 的报错它自己已经打过了;没构建成功就不装,Plume 里还是上一份能用的。
|
|
32
|
+
if (!error?.errors) log(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
round();
|
|
38
|
+
const watchers = [
|
|
39
|
+
watch(join(dir, "src"), { recursive: true }, schedule),
|
|
40
|
+
watch(dir, (_event, name) => {
|
|
41
|
+
if (name === "package.json" || name === "README.md") schedule();
|
|
42
|
+
}),
|
|
43
|
+
];
|
|
44
|
+
function schedule() {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
timer = setTimeout(round, SETTLE_MS);
|
|
47
|
+
}
|
|
48
|
+
return () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
for (const watcher of watchers) watcher.close();
|
|
51
|
+
};
|
|
52
|
+
}
|
package/lib/icons.mjs
ADDED
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 template/ 生成一个新插件。
|
|
3
|
+
*
|
|
4
|
+
* 两种情况:
|
|
5
|
+
* - 独立项目(SDK 随 Plume 应用发来,或者目标不在 Plume 仓库里):不经 npm,node_modules 里链到这份 SDK(见 link.mjs);
|
|
6
|
+
* 另外带上 skill、AGENTS.md、CLAUDE.md、.gitignore,让接手的 agent 一打开项目就知道怎么写。
|
|
7
|
+
* - 目标在一个 pnpm workspace 里(Plume 本体仓库,或插件仓库 plume-plugins 这样的插件集合):本体仓库里 SDK 依赖写
|
|
8
|
+
* `workspace:*`,别的 workspace 里写 npm 上的版本 `^<这份 SDK 的版本>`;skill 和 AGENTS.md 用仓库根目录的,不另带;
|
|
9
|
+
* 构建交给仓库的 `pnpm build:plugins`。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
import { linkSdk } from "./link.mjs";
|
|
15
|
+
import { DIR_NAME } from "./manifest.mjs";
|
|
16
|
+
|
|
17
|
+
const sdk = resolve(import.meta.dirname, "..");
|
|
18
|
+
const manifest = JSON.parse(readFileSync(join(sdk, "package.json"), "utf8"));
|
|
19
|
+
|
|
20
|
+
/** 插件名:小写,点分段,段里可以有连字符。 */
|
|
21
|
+
export const PLUGIN_NAME = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)*$/;
|
|
22
|
+
|
|
23
|
+
/** 只给独立项目的文件。 */
|
|
24
|
+
const STANDALONE_ONLY = new Set(["AGENTS.md", "CLAUDE.md", "gitignore"]);
|
|
25
|
+
|
|
26
|
+
/** `start` 所在的 pnpm workspace 根目录(往上找 pnpm-workspace.yaml)。 */
|
|
27
|
+
function workspaceOf(start) {
|
|
28
|
+
for (let dir = start; dir !== dirname(dir); dir = dirname(dir)) {
|
|
29
|
+
if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 这份 SDK 是从 npm 装进某个项目的(在 node_modules 里)。 */
|
|
35
|
+
const installed = sdk.split(sep).includes("node_modules");
|
|
36
|
+
|
|
37
|
+
/** SDK 源码所在的 pnpm workspace 根目录(Plume 本体仓库);随应用发的、从 npm 装的那份没有。 */
|
|
38
|
+
function workspaceRoot() {
|
|
39
|
+
return installed ? undefined : workspaceOf(sdk);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** skill 的来源:随应用发的那份里是 skill/(打包时复制进去),仓库里是根目录的 skills/plume-plugin。 */
|
|
43
|
+
export function skillDir() {
|
|
44
|
+
const packed = join(sdk, "skill");
|
|
45
|
+
if (existsSync(join(packed, "SKILL.md"))) return packed;
|
|
46
|
+
const root = workspaceRoot();
|
|
47
|
+
const repo = root && join(root, "skills/plume-plugin");
|
|
48
|
+
if (repo && existsSync(join(repo, "SKILL.md"))) return repo;
|
|
49
|
+
throw new Error("找不到 skill plume-plugin");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function copyTemplate(from, to, replace, skip) {
|
|
53
|
+
mkdirSync(to, { recursive: true });
|
|
54
|
+
for (const name of readdirSync(from)) {
|
|
55
|
+
if (skip(name)) continue;
|
|
56
|
+
const source = join(from, name);
|
|
57
|
+
const target = join(to, name === "gitignore" ? ".gitignore" : name);
|
|
58
|
+
if (statSync(source).isDirectory()) copyTemplate(source, target, replace, () => false);
|
|
59
|
+
else writeFileSync(target, replace(readFileSync(source, "utf8")));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} target 新插件的目录,不能已经存在
|
|
65
|
+
* @param {{ name?: string, displayName?: string }} [options]
|
|
66
|
+
* @returns {{ dir: string, name: string, workspace: boolean }}
|
|
67
|
+
*/
|
|
68
|
+
export function initPlugin(target, options = {}) {
|
|
69
|
+
const dir = resolve(target);
|
|
70
|
+
const key = basename(dir);
|
|
71
|
+
if (!DIR_NAME.test(key)) throw new Error(`目录名 ${key} 不行:只能用小写字母、数字和连字符`);
|
|
72
|
+
if (existsSync(dir)) throw new Error(`${dir} 已经存在`);
|
|
73
|
+
const root = workspaceRoot();
|
|
74
|
+
// 目标所在的 workspace:SDK 源码所在的本体仓库,或者从 npm 装了 SDK 的仓库(比如插件仓库)。随应用发的 SDK 总是建独立项目。
|
|
75
|
+
const home = root !== undefined || installed ? workspaceOf(dirname(dir)) : undefined;
|
|
76
|
+
const workspace = home !== undefined;
|
|
77
|
+
const sdkDep = home !== undefined && home === root ? "workspace:*" : `^${manifest.version}`;
|
|
78
|
+
const name = options.name ?? (workspace ? `plume.${key}` : key);
|
|
79
|
+
if (!PLUGIN_NAME.test(name)) throw new Error(`插件名 ${name} 不行:小写字母、数字、连字符,用点分段`);
|
|
80
|
+
const displayName = options.displayName ?? key;
|
|
81
|
+
const tool = `${key.replaceAll("-", "_")}_greet`;
|
|
82
|
+
|
|
83
|
+
const replace = (text) => text.replaceAll("__NAME__", name).replaceAll("__TOOL__", tool).replaceAll("__DISPLAY__", displayName);
|
|
84
|
+
copyTemplate(join(sdk, "template"), dir, replace, (file) => workspace && STANDALONE_ONLY.has(file));
|
|
85
|
+
|
|
86
|
+
const deps = manifest.dependencies;
|
|
87
|
+
const pkg = {
|
|
88
|
+
name: `@plume-plugins/${key}`,
|
|
89
|
+
version: "0.1.0",
|
|
90
|
+
description: "",
|
|
91
|
+
keywords: [],
|
|
92
|
+
plume: { displayName, category: "" },
|
|
93
|
+
private: true,
|
|
94
|
+
type: "module",
|
|
95
|
+
// 仓库里照 workspace 的规矩写依赖、由 pnpm 装;独立项目不经 npm,靠 link.mjs 建的链接。
|
|
96
|
+
...(workspace
|
|
97
|
+
? {
|
|
98
|
+
scripts: { typecheck: "tsc -p tsconfig.json" },
|
|
99
|
+
devDependencies: { "@plumeai/plugin-sdk": sdkDep, "@types/node": deps["@types/node"], "@types/react": deps["@types/react"] },
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
writeFileSync(join(dir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
104
|
+
if (!workspace) {
|
|
105
|
+
cpSync(skillDir(), join(dir, ".claude/skills/plume-plugin"), { recursive: true, dereference: true });
|
|
106
|
+
linkSdk(dir);
|
|
107
|
+
}
|
|
108
|
+
return { dir, name, workspace };
|
|
109
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把构建好的插件装进本机的 Plume:复制 package.json、README.md、images/、icon.png、dist/、随包的 skills/、.mcp.json 和 LICENSE 到 `~/.plume/plugins/<目录名>/`,
|
|
3
|
+
* 和插件市场装的放在同一个地方。Plume 监视这个目录,开着的话当场加载(已经装过的就换成新的)。
|
|
4
|
+
*
|
|
5
|
+
* 先复制进旁边的暂存目录再整个换上去:Plume 看到的要么是旧的一份,要么是新的一份,不会是复制到一半的。
|
|
6
|
+
*
|
|
7
|
+
* 换的时候先把旧目录挪开、再把新目录挪进来,最后才删旧的;不先 `rm -r`:Windows 上旧版本里还有开着的文件
|
|
8
|
+
* (插件起的 `.exe`、编辑器打开着的文件)时删不掉,`rm -r` 会把它周围的删光、卡在它上面,留下一个删了一半的插件。
|
|
9
|
+
* 挪开整个目录在这时是整个被拒的,什么都不动,旧的那份照常能用。和应用里市场的装法(apps/desktop 的 plugin-swap.ts)一样。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
import { DIR_NAME, PLUGIN_ENTRY, SHIPPED } from "./manifest.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} source 插件目录
|
|
20
|
+
* @param {string} [root] 装到哪个目录下,缺省 Plume 数据目录(`PLUME_HOME`,没设是 ~/.plume)下的 plugins
|
|
21
|
+
* @returns {string} 装到的目录
|
|
22
|
+
*/
|
|
23
|
+
export function installPlugin(source, root = join(process.env.PLUME_HOME || join(homedir(), ".plume"), "plugins")) {
|
|
24
|
+
const dir = resolve(source);
|
|
25
|
+
const key = basename(dir);
|
|
26
|
+
if (!DIR_NAME.test(key)) throw new Error(`目录名 ${key} 不行:只能用小写字母、数字和连字符`);
|
|
27
|
+
if (!existsSync(join(dir, PLUGIN_ENTRY))) throw new Error(`没有 ${PLUGIN_ENTRY},先构建`);
|
|
28
|
+
const target = join(root, key);
|
|
29
|
+
// 暂存目录不在 root 里面:Plume 只认 root 下一层的目录,暂存的不会被当成插件。
|
|
30
|
+
const staging = join(root, "..", ".plugins-staging", key);
|
|
31
|
+
rmSync(staging, { recursive: true, force: true });
|
|
32
|
+
mkdirSync(staging, { recursive: true });
|
|
33
|
+
for (const name of SHIPPED) {
|
|
34
|
+
if (existsSync(join(dir, name))) cpSync(join(dir, name), join(staging, name), { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
mkdirSync(root, { recursive: true });
|
|
37
|
+
const retired = join(root, "..", ".plugins-retired", `${Date.now()}-${randomUUID().slice(0, 8)}-${key}`);
|
|
38
|
+
if (existsSync(target)) {
|
|
39
|
+
mkdirSync(dirname(retired), { recursive: true });
|
|
40
|
+
try {
|
|
41
|
+
renameWithRetry(target, retired);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
rmSync(staging, { recursive: true, force: true });
|
|
44
|
+
throw busy(error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
renameWithRetry(staging, target);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (existsSync(retired)) renameSync(retired, target);
|
|
51
|
+
rmSync(staging, { recursive: true, force: true });
|
|
52
|
+
throw busy(error);
|
|
53
|
+
}
|
|
54
|
+
// 尽力删:还有文件开着的留在 .plugins-retired 里,Plume 下次换插件时清掉。
|
|
55
|
+
rmSync(retired, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Windows 上别的进程开着文件(或目录里的文件)时改名报的错。 */
|
|
60
|
+
const HELD_OPEN = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
61
|
+
const heldOpen = (error) => process.platform === "win32" && HELD_OPEN.has(error?.code);
|
|
62
|
+
|
|
63
|
+
/** 改名;Windows 上被占用时等一等再试(杀毒软件、索引会去读刚写完的文件),每次多等 50ms,一共 8 次。 */
|
|
64
|
+
function renameWithRetry(from, to) {
|
|
65
|
+
for (let attempt = 1; ; attempt++) {
|
|
66
|
+
try {
|
|
67
|
+
renameSync(from, to);
|
|
68
|
+
return;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (attempt >= 8 || !heldOpen(error)) throw error;
|
|
71
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * attempt);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function busy(error) {
|
|
77
|
+
return heldOpen(error)
|
|
78
|
+
? new Error("插件的文件正被占用(多半是它启动的程序,或打开着它的编辑器),什么都没有改动。关掉占用它的程序后再试。", { cause: error })
|
|
79
|
+
: error;
|
|
80
|
+
}
|
package/lib/link.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 让插件项目找到 SDK:在它的 node_modules 里建几个链接,指到这份 SDK(随 Plume 应用发的那份,或仓库里这份)。
|
|
3
|
+
*
|
|
4
|
+
* 不经 npm:插件的 tsconfig 写 `"extends": "@plumeai/plugin-sdk/tsconfig.json"`,`types` 写 `["node"]`,
|
|
5
|
+
* 这几个名字靠链接解析到 SDK 自带的副本上,插件的写法和仓库里的一样。
|
|
6
|
+
* Plume 挪了位置,或者用 npm 装了别的包(npm 会删掉它不认识的链接),重新跑一次 `plume-plugin link`。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
const sdk = resolve(import.meta.dirname, "..");
|
|
14
|
+
const require = createRequire(join(sdk, "package.json"));
|
|
15
|
+
|
|
16
|
+
/** 链进插件 node_modules 的包 → 它在 SDK 这边的目录。 */
|
|
17
|
+
function targets() {
|
|
18
|
+
return {
|
|
19
|
+
"@plumeai/plugin-sdk": sdk,
|
|
20
|
+
"@types/node": dirname(require.resolve("@types/node/package.json")),
|
|
21
|
+
"@types/react": dirname(require.resolve("@types/react/package.json")),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} target 插件目录 */
|
|
26
|
+
export function linkSdk(target) {
|
|
27
|
+
const dir = resolve(target);
|
|
28
|
+
for (const [name, source] of Object.entries(targets())) {
|
|
29
|
+
const path = join(dir, "node_modules", name);
|
|
30
|
+
// 已有的(旧链接,或者 npm 装的真目录)换掉:用的必须是这份 SDK。
|
|
31
|
+
if (existsSync(path) || isLink(path)) rmSync(path, { recursive: true, force: true });
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
// Windows 上建目录链接要管理员权限,junction 不用。
|
|
34
|
+
symlinkSync(source, path, process.platform === "win32" ? "junction" : "dir");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isLink(path) {
|
|
39
|
+
try {
|
|
40
|
+
return lstatSync(path).isSymbolicLink();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 硬编码中文:界面上给人读的文字要走 i18n,不能直接写在代码里。插件检查(`plume-plugin check`)和 Plume 自己的
|
|
3
|
+
* `pnpm check:i18n` 用的是这同一份。
|
|
4
|
+
*
|
|
5
|
+
* 算一条的:字符串字面量或 JSX 文本里有汉字或中文标点。不算的:注释(注释本来就用中文写)、
|
|
6
|
+
* 日志调用的参数(`console.*` 和 Plume 的 log,写给调试的人看的)、紧跟在 `i18n-exempt` 注释后面、到下一个空行为止的几行
|
|
7
|
+
* (给人读但不能翻译的字:发给模型的提示词、和存量数据比对的固定字符串……注释里写明原因)。
|
|
8
|
+
*
|
|
9
|
+
* 中文标点单独算:词都翻译完以后剩下的就是 `parts.join("、")`、`${name}:${message}` 这种,
|
|
10
|
+
* 只查汉字会放过它们,而它们在英文界面里会夹出中文标点。
|
|
11
|
+
*
|
|
12
|
+
* 插件的文案:自带中英两份,放在 src/messages.ts(不查);界面入口用 `@plume/ui` 的 useI18n() 取当前语言,逻辑入口用 ctx.locale()。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 汉字,和中文句子用的标点(分隔、括号、句读;不是全部全角字符)。 */
|
|
16
|
+
const CHINESE = /[一-鿿、,:;()「」『』。!?]/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 去掉注释,换行保留(块注释里的也一个换一个),行号才对得上,`i18n-exempt` 也按行认。
|
|
20
|
+
* 认得字符串:示例 URL 里的 `//` 不是注释,当成注释会把那一行后面的文字都跳过去。
|
|
21
|
+
* 模板字符串会嵌套,`${…}` 里又可以有字符串和注释,所以用一个栈。
|
|
22
|
+
*/
|
|
23
|
+
function stripComments(src) {
|
|
24
|
+
let out = "";
|
|
25
|
+
const stack = [];
|
|
26
|
+
const inside = () => stack[stack.length - 1];
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < src.length;) {
|
|
29
|
+
const char = src[i];
|
|
30
|
+
const two = src.slice(i, i + 2);
|
|
31
|
+
const state = inside();
|
|
32
|
+
|
|
33
|
+
if (state === "'" || state === '"' || state === "`") {
|
|
34
|
+
if (char === "\\") {
|
|
35
|
+
out += two;
|
|
36
|
+
i += 2;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (char === state) stack.pop();
|
|
40
|
+
else if (state === "`" && two === "${") {
|
|
41
|
+
stack.push("{");
|
|
42
|
+
out += two;
|
|
43
|
+
i += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
out += char;
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 代码,或者 `${…}` 里面:这里的注释都是注释。
|
|
52
|
+
if (two === "//") {
|
|
53
|
+
// 停在换行前,让循环自己把换行抄过去;这里再补一个,后面的行号都会错一行。
|
|
54
|
+
const end = src.indexOf("\n", i);
|
|
55
|
+
i = end === -1 ? src.length : end;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (two === "/*") {
|
|
59
|
+
const end = src.indexOf("*/", i + 2);
|
|
60
|
+
const body = src.slice(i, end === -1 ? src.length : end + 2);
|
|
61
|
+
i = end === -1 ? src.length : end + 2;
|
|
62
|
+
out += body.replace(/[^\n]/g, "");
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (char === "'" || char === '"' || char === "`") stack.push(char);
|
|
66
|
+
else if (char === "{") stack.push("{");
|
|
67
|
+
else if (char === "}" && state === "{") stack.pop();
|
|
68
|
+
out += char;
|
|
69
|
+
i += 1;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 只进日志的调用:`console.*`,和 Plume 自己的日志函数(`log(…)`、`this.options.log(…)`、`ctx.log.warn(…)`)。 */
|
|
75
|
+
const LOG_CALL = /\b(?:console\s*\.\s*(?:log|info|warn|error|debug|trace)|(?:\w+\s*\.\s*)*log(?:\s*\.\s*(?:info|warn|error))?)\s*\(/g;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 把日志调用的参数整段抹掉(换行保留):只进日志的不算。
|
|
79
|
+
* 找调用时先把字符串内容遮住,字符串里恰好有 `console.log(` 不会被当成调用,日志字符串里的括号也不会提前结束它。
|
|
80
|
+
*/
|
|
81
|
+
function stripLogs(stripped) {
|
|
82
|
+
const masked = stripped.replace(
|
|
83
|
+
/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g,
|
|
84
|
+
(literal) => literal[0] + literal.slice(1, -1).replace(/[^\n]/g, " ") + literal.at(-1),
|
|
85
|
+
);
|
|
86
|
+
let out = stripped;
|
|
87
|
+
for (const call of masked.matchAll(LOG_CALL)) {
|
|
88
|
+
const open = call.index + call[0].length - 1;
|
|
89
|
+
let close = masked.length;
|
|
90
|
+
for (let i = open, depth = 0; i < masked.length; i++) {
|
|
91
|
+
if (masked[i] === "(") depth += 1;
|
|
92
|
+
else if (masked[i] === ")" && --depth === 0) {
|
|
93
|
+
close = i;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
out = out.slice(0, open + 1) + out.slice(open + 1, close).replace(/[^\n]/g, " ") + out.slice(close);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `i18n-exempt` 注释管到的行:从下一行到第一个空行。
|
|
104
|
+
*
|
|
105
|
+
* 给有这样一种字留的:人读不到、也不能跟着语言变的固定字符串(发给模型的提示词、和存量数据比对的词)。
|
|
106
|
+
* 整个文件豁免太宽(同一个文件里往往还有真正的界面文案),所以豁免写在它管的那几行上面,原因写在注释里。
|
|
107
|
+
* 一条注释管到空行为止,一张表只要写一次。
|
|
108
|
+
*/
|
|
109
|
+
function exemptLines(source) {
|
|
110
|
+
const lines = source.split("\n");
|
|
111
|
+
const exempt = new Set();
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
if (!/i18n-exempt\b/.test(lines[i])) continue;
|
|
114
|
+
for (let j = i + 1; j < lines.length && lines[j].trim() !== ""; j++) exempt.add(j + 1);
|
|
115
|
+
}
|
|
116
|
+
return exempt;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 一个文件里所有给人读的中文,`{ line, text }`。
|
|
121
|
+
*
|
|
122
|
+
* 先认字符串字面量,再把它们的内容遮住:剩下还有中文的,只能是 JSX 文本(`.tsx` 里标签之间的字,
|
|
123
|
+
* 包括写在 `{cond && <p>…</p>}` 这种表达式里的)。不去配对标签和花括号:配对的写法会被箭头函数的 `>`
|
|
124
|
+
* 和表达式里的 `n > 1` 带偏,整段跳过真正的文字。
|
|
125
|
+
*
|
|
126
|
+
* @param {string} source
|
|
127
|
+
* @returns {{ line: number, text: string }[]}
|
|
128
|
+
*/
|
|
129
|
+
export function chineseFindings(source) {
|
|
130
|
+
const stripped = stripLogs(stripComments(source));
|
|
131
|
+
const spared = exemptLines(source);
|
|
132
|
+
const found = [];
|
|
133
|
+
const at = (index) => stripped.slice(0, index).split("\n").length;
|
|
134
|
+
const keep = (line, text) => {
|
|
135
|
+
if (!spared.has(line)) found.push({ line, text });
|
|
136
|
+
};
|
|
137
|
+
// 认得转义,`"他说 \"好\""` 是一条,不是两条。
|
|
138
|
+
const literal = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
|
|
139
|
+
for (const match of stripped.matchAll(literal)) {
|
|
140
|
+
if (CHINESE.test(match[0])) keep(at(match.index), match[0].trim().slice(0, 80));
|
|
141
|
+
}
|
|
142
|
+
const masked = stripped.replace(literal, (text) => text.replace(/[^\n]/g, " "));
|
|
143
|
+
masked.split("\n").forEach((line, index) => {
|
|
144
|
+
// 字符串已经遮住,剩下的 `//` 是注释(正则字面量里的引号可能让前面没认出它)。
|
|
145
|
+
const code = line.replace(/\/\/.*$/, "");
|
|
146
|
+
if (CHINESE.test(code)) keep(index + 1, code.trim().replace(/\s+/g, " ").slice(0, 80));
|
|
147
|
+
});
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件只引用公开接口(插件检查 `plume-plugin check` 和 Plume 自己的 `pnpm check:plugins` 用的是这同一份)。
|
|
3
|
+
* 插件 src 里的 import / export from / import() / require() 只能是
|
|
4
|
+
* - 相对路径,且不出插件自己的目录;
|
|
5
|
+
* - `node:*`;
|
|
6
|
+
* - `@plume/contract`、`@plume/contract/ui`,而且只能 `import type` / `export type`(它只有类型,运行时从 ctx / ui 上拿);
|
|
7
|
+
* - 界面入口共享的 `react`、`react/jsx-runtime`、`@plume/ui`、`@plume/ui/icons`、`@plume/styles`;
|
|
8
|
+
* - 插件自己 package.json 里声明的第三方包(及其子路径)。
|
|
9
|
+
* `@plume/` 下的其他包是宿主的实现,声明了也不行。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
const CONTRACT = new Set(["@plume/contract", "@plume/contract/ui"]);
|
|
16
|
+
const SHARED = new Set(["react", "react/jsx-runtime", "@plume/ui", "@plume/ui/icons", "@plume/styles"]);
|
|
17
|
+
|
|
18
|
+
/** 去掉注释。行注释只认行首或空白后的 `//`,字符串里的 URL 不受影响。 */
|
|
19
|
+
export const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
|
20
|
+
|
|
21
|
+
/** 包名:`@scope/name/sub` → `@scope/name`,`name/sub` → `name`。 */
|
|
22
|
+
const packageOf = (specifier) =>
|
|
23
|
+
specifier
|
|
24
|
+
.split("/")
|
|
25
|
+
.slice(0, specifier.startsWith("@") ? 2 : 1)
|
|
26
|
+
.join("/");
|
|
27
|
+
|
|
28
|
+
/** 一个文件里的引用:{ specifier, typeOnly }。import 子句只由标识符、花括号、逗号、星号、空白组成,不会跨进代码里。 */
|
|
29
|
+
function imports(text) {
|
|
30
|
+
const code = stripComments(text);
|
|
31
|
+
const found = [];
|
|
32
|
+
for (const match of code.matchAll(/\bimport\s+(type\s+)?(?:[\w$*{},\s]+?\s+from\s*)?["']([^"']+)["']/g))
|
|
33
|
+
found.push({ specifier: match[2], typeOnly: Boolean(match[1]) });
|
|
34
|
+
for (const match of code.matchAll(/\bexport\s+(type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[\w$,\s]*\})\s*from\s*["']([^"']+)["']/g))
|
|
35
|
+
found.push({ specifier: match[2], typeOnly: Boolean(match[1]) });
|
|
36
|
+
// `typeof import("x")` 只是类型,但和运行时的 import() 一样要在白名单里,按非纯类型算更严。
|
|
37
|
+
for (const match of code.matchAll(/\b(?:import|require)\s*\(\s*["']([^"']+)["']\s*\)/g)) found.push({ specifier: match[1], typeOnly: false });
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** dir 下的 .ts、.tsx,跳过 node_modules、dist。 */
|
|
42
|
+
function sourceFiles(dir, out = []) {
|
|
43
|
+
if (!existsSync(dir)) return out;
|
|
44
|
+
for (const name of readdirSync(dir)) {
|
|
45
|
+
if (["node_modules", "dist"].includes(name)) continue;
|
|
46
|
+
const path = join(dir, name);
|
|
47
|
+
if (statSync(path).isDirectory()) sourceFiles(path, out);
|
|
48
|
+
else if (/\.tsx?$/.test(name)) out.push(path);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 一个插件的越界引用。返回 `{ file, message }`。
|
|
55
|
+
* @param {string} pluginDir 插件目录(有 package.json 和 src/)
|
|
56
|
+
* @param {{ allow?: Set<string> }} [options] allow:额外允许、声明了就能用的 `@plume/` 包(Plume 仓库里的内置插件用 packages/infra 下的通用库)
|
|
57
|
+
*/
|
|
58
|
+
export function importProblems(pluginDir, options = {}) {
|
|
59
|
+
const allow = options.allow ?? new Set();
|
|
60
|
+
const manifest = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8"));
|
|
61
|
+
const declared = new Set(
|
|
62
|
+
Object.keys({ ...manifest.dependencies, ...manifest.devDependencies }).filter((dep) => !dep.startsWith("@plume/") || allow.has(dep)),
|
|
63
|
+
);
|
|
64
|
+
const found = [];
|
|
65
|
+
for (const file of sourceFiles(join(pluginDir, "src"))) {
|
|
66
|
+
const report = (message) => found.push({ file, message });
|
|
67
|
+
for (const { specifier, typeOnly } of imports(readFileSync(file, "utf8"))) {
|
|
68
|
+
if (specifier.startsWith(".")) {
|
|
69
|
+
const target = resolve(dirname(file), specifier);
|
|
70
|
+
if (target !== pluginDir && !target.startsWith(pluginDir + sep)) report(`${specifier}:出了插件自己的目录`);
|
|
71
|
+
} else if (specifier.startsWith("node:") || SHARED.has(specifier)) {
|
|
72
|
+
// 公开的运行时模块。
|
|
73
|
+
} else if (CONTRACT.has(specifier)) {
|
|
74
|
+
if (!typeOnly) report(`${specifier}:只能 import type / export type,运行时的东西从 ctx / ui 上拿`);
|
|
75
|
+
} else if (specifier.startsWith("@plume/") && !allow.has(packageOf(specifier))) {
|
|
76
|
+
report(`${specifier}:不是公开接口,插件只能用 @plume/contract(类型)、@plume/ui、@plume/ui/icons、@plume/styles`);
|
|
77
|
+
} else if (!declared.has(packageOf(specifier))) {
|
|
78
|
+
report(`${specifier}:没在插件的 package.json 里声明`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件检查里类型检查之外的规则:只引用公开接口(imports.mjs)、样式(styles.mjs)、不写死中文(i18n.mjs)。
|
|
3
|
+
* `plume-plugin check` 和插件仓库的检查用 lintPlugin;Plume 自己的检查脚本按规则分别引用这几个模块。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { chineseFindings } from "./i18n.mjs";
|
|
9
|
+
import { importProblems } from "./imports.mjs";
|
|
10
|
+
import { pluginStyleProblems, pluginTailwind } from "./styles.mjs";
|
|
11
|
+
|
|
12
|
+
// 一次查很多个插件时先编译一次 Tailwind,作为 isTailwind 传给每次 lintPlugin。
|
|
13
|
+
export { pluginTailwind };
|
|
14
|
+
|
|
15
|
+
/** dir 下扩展名在 exts 里的文件,跳过 node_modules、dist、out 和 skip 里的目录名。 */
|
|
16
|
+
function files(dir, exts, skip = [], out = []) {
|
|
17
|
+
if (!existsSync(dir)) return out;
|
|
18
|
+
for (const name of readdirSync(dir)) {
|
|
19
|
+
if (["node_modules", "dist", "out", ...skip].includes(name)) continue;
|
|
20
|
+
const path = join(dir, name);
|
|
21
|
+
if (statSync(path).isDirectory()) files(path, exts, skip, out);
|
|
22
|
+
else if (exts.some((ext) => name.endsWith(ext))) out.push(path);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ file: string, line?: number, rule: string, message: string }} Problem
|
|
31
|
+
* @param {string} pluginDir
|
|
32
|
+
* @param {{ isTailwind?: (candidate: string) => boolean }} [options] isTailwind 缺省用 SDK 带的 Tailwind 和 theme.css
|
|
33
|
+
* @returns {Promise<Problem[]>}
|
|
34
|
+
*/
|
|
35
|
+
export async function lintPlugin(pluginDir, options = {}) {
|
|
36
|
+
const isTailwind = options.isTailwind ?? (await pluginTailwind());
|
|
37
|
+
const problems = importProblems(pluginDir).map(({ file, message }) => ({ file, rule: "引用", message }));
|
|
38
|
+
for (const file of files(pluginDir, [".ts", ".tsx", ".css"])) {
|
|
39
|
+
const text = readFileSync(file, "utf8");
|
|
40
|
+
for (const { index, rule, message } of pluginStyleProblems(file, text, isTailwind)) problems.push({ file, line: lineAt(text, index), rule, message });
|
|
41
|
+
}
|
|
42
|
+
// 测试写给开发者看,文案在 src/messages.ts 里本来就是两种语言:都不查中文。
|
|
43
|
+
for (const file of files(pluginDir, [".ts", ".tsx"], ["test"])) {
|
|
44
|
+
if (relative(pluginDir, file).replaceAll("\\", "/") === "src/messages.ts") continue;
|
|
45
|
+
for (const { line, text } of chineseFindings(readFileSync(file, "utf8")))
|
|
46
|
+
problems.push({ file, line, rule: "中文", message: `${text}:界面文案放进 src/messages.ts,按语言取(useI18n()、ctx.locale())` });
|
|
47
|
+
}
|
|
48
|
+
return problems;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 一条问题写成一行,路径相对 base。 */
|
|
52
|
+
export function formatProblem({ file, line, rule, message }, base) {
|
|
53
|
+
return `${relative(base, file)}${line ? `:${line}` : ""} [${rule}] ${message}`;
|
|
54
|
+
}
|