@rainynest/ext 0.0.0-stage → 0.1.1
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/README.md +26 -2
- package/bin/ext.js +195 -0
- package/lib/bundle.js +92 -0
- package/lib/pack.js +61 -0
- package/lib/validate.js +89 -0
- package/package.json +22 -4
- package/templates/plugin/README.md +20 -0
- package/templates/plugin/plugin.json +21 -0
- package/templates/plugin/src/admin.tsx +11 -0
- package/templates/plugin/src/index.ts +12 -0
- package/templates/theme/src/theme.ts +8 -0
- package/templates/theme/theme.css +1 -0
- package/templates/theme/theme.json +26 -0
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rainynest/ext
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
RainyNest 插件/主题开发 CLI。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm create rainynest-ext my-plugin # 生成插件工程(推荐)
|
|
9
|
+
cd my-plugin && npm install
|
|
10
|
+
npm run dev # 联调热更新
|
|
11
|
+
npm run pack # 产出发布包
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 命令
|
|
15
|
+
|
|
16
|
+
| 命令 | 作用 |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `ext new plugin\|theme <名称>` | 在当前目录生成模板工程 |
|
|
19
|
+
| `ext build <目录> -o dist` | esbuild 编译 → 安装态产物 |
|
|
20
|
+
| `ext build-all --src . --out ../scripts` | 全量编译多扩展目录 |
|
|
21
|
+
| `ext pack <目录> [--format zip\|gz]` | 打发布包(`.zip.rainy-plugin` / `.gz.rainy-theme`) |
|
|
22
|
+
| `ext dev <目录> --site <url>` | watch 编译 + 热更新(需 `EXT_DEV_TOKEN`) |
|
|
23
|
+
|
|
24
|
+
## 说明
|
|
25
|
+
|
|
26
|
+
- 类型定义请安装 `@rainynest/plugin-types`
|
|
27
|
+
- 插件/主题包上传到 RainyNest 后台「系统 → 插件 / 主题」即可安装
|
package/bin/ext.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/ext-cli/bin/ext.js — RainyNest 扩展 CLI
|
|
3
|
+
// 用法:
|
|
4
|
+
// ext new plugin <name> 生成插件模板工程
|
|
5
|
+
// ext new theme <name> 生成主题模板工程
|
|
6
|
+
// ext build <srcDir> [-o outDir] 编译单个扩展为安装态产物
|
|
7
|
+
// ext build-all --src packages --out scripts 批量编译(内置包迁移用)
|
|
8
|
+
// ext pack <builtDir> [--format zip|gz] [-o dist] 打包发布产物 *.zip.rainy-plugin 等
|
|
9
|
+
// ext dev <srcDir> [--site url] watch + 热更新上传(管理 token)
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const { buildOne } = require('../lib/bundle');
|
|
14
|
+
const { validateManifest, scanArtifacts } = require('../lib/validate');
|
|
15
|
+
const { pack } = require('../lib/pack');
|
|
16
|
+
|
|
17
|
+
function fail(msg) {
|
|
18
|
+
console.error('[ext] ✗ ' + msg);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = { _: [] };
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
if (a.startsWith('--')) {
|
|
27
|
+
const key = a.slice(2);
|
|
28
|
+
const next = argv[i + 1];
|
|
29
|
+
if (next !== undefined && !next.startsWith('--')) { args[key] = next; i++; }
|
|
30
|
+
else args[key] = true;
|
|
31
|
+
} else if (a === '-o') {
|
|
32
|
+
args.out = argv[++i];
|
|
33
|
+
} else {
|
|
34
|
+
args._.push(a);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── ext new ───
|
|
41
|
+
function cmdNew(args) {
|
|
42
|
+
const kind = args._[1]; // plugin | theme
|
|
43
|
+
const name = args._[2];
|
|
44
|
+
if (!['plugin', 'theme'].includes(kind)) fail('用法: ext new plugin|theme <名称>');
|
|
45
|
+
if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) fail('名称只允许小写字母/数字/连字符');
|
|
46
|
+
|
|
47
|
+
const tplDir = path.join(__dirname, '..', 'templates', kind);
|
|
48
|
+
const dest = path.join(process.cwd(), name);
|
|
49
|
+
if (fs.existsSync(dest)) fail(`目录已存在: ${dest}`);
|
|
50
|
+
|
|
51
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
52
|
+
copyTpl(tplDir, dest, name);
|
|
53
|
+
console.log(`[ext] ✓ 已生成${kind === 'plugin' ? '插件' : '主题'}模板: ${dest}`);
|
|
54
|
+
console.log(' 下一步: 修改清单与 src/ 源码 → ext build ' + name + ' -o dist');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function copyTpl(src, dest, name) {
|
|
58
|
+
for (const item of fs.readdirSync(src)) {
|
|
59
|
+
const s = path.join(src, item);
|
|
60
|
+
const d = path.join(dest, item);
|
|
61
|
+
if (fs.statSync(s).isDirectory()) { fs.mkdirSync(d, { recursive: true }); copyTpl(s, d, name); continue; }
|
|
62
|
+
let content = fs.readFileSync(s, 'utf-8').replace(/__SLUG__/g, name);
|
|
63
|
+
fs.writeFileSync(d, content);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── ext build ───
|
|
68
|
+
function cmdBuild(args) {
|
|
69
|
+
const srcDir = path.resolve(args._[1] || '.');
|
|
70
|
+
const outDir = path.resolve(args.out || path.join(process.cwd(), 'dist'));
|
|
71
|
+
const check = validateManifest(srcDir);
|
|
72
|
+
if (check.errors.length) fail(check.errors.join('; '));
|
|
73
|
+
for (const w of check.warnings) console.warn('[ext] ! ' + w);
|
|
74
|
+
|
|
75
|
+
const built = buildOne(srcDir, outDir);
|
|
76
|
+
const artifactErrors = scanArtifacts(outDir);
|
|
77
|
+
if (artifactErrors.length) fail(artifactErrors.join('; '));
|
|
78
|
+
|
|
79
|
+
console.log(`[ext] ✓ ${check.kind}「${check.manifest.slug}」→ ${outDir} [${built.join(', ')}]`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── ext build-all ───
|
|
83
|
+
function cmdBuildAll(args) {
|
|
84
|
+
const srcRoot = path.resolve(args.src || 'packages');
|
|
85
|
+
const outRoot = path.resolve(args.out || 'scripts');
|
|
86
|
+
if (!fs.existsSync(srcRoot)) fail(`源码目录不存在: ${srcRoot}`);
|
|
87
|
+
|
|
88
|
+
let total = 0;
|
|
89
|
+
for (const kind of ['plugins', 'themes']) {
|
|
90
|
+
const srcDir = path.join(srcRoot, kind);
|
|
91
|
+
if (!fs.existsSync(srcDir)) continue;
|
|
92
|
+
for (const slug of fs.readdirSync(srcDir)) {
|
|
93
|
+
const dir = path.join(srcDir, slug);
|
|
94
|
+
if (!fs.statSync(dir).isDirectory()) continue;
|
|
95
|
+
const check = validateManifest(dir);
|
|
96
|
+
if (check.errors.length) { console.error(`[ext] ✗ ${slug}: ${check.errors.join('; ')}`); continue; }
|
|
97
|
+
const built = buildOne(dir, path.join(outRoot, kind, slug));
|
|
98
|
+
const errs = scanArtifacts(path.join(outRoot, kind, slug));
|
|
99
|
+
if (errs.length) { console.error(`[ext] ✗ ${slug}: ${errs.join('; ')}`); continue; }
|
|
100
|
+
console.log(`[ext] ✓ ${kind}/${slug} [${built.join(', ') || '静态复制'}]`);
|
|
101
|
+
total++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log(`[ext] ✓ 共 ${total} 个扩展已编译到 ${outRoot}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── ext pack ───
|
|
108
|
+
function cmdPack(args) {
|
|
109
|
+
const dir = path.resolve(args._[1] || '.');
|
|
110
|
+
const check = validateManifest(dir);
|
|
111
|
+
if (check.errors.length) fail(check.errors.join('; '));
|
|
112
|
+
// 默认格式:插件 zip、主题 gz(用户决策),--format 可覆盖
|
|
113
|
+
const format = args.format ? (args.format === 'gz' ? 'gz' : 'zip') : (check.kind === 'theme' ? 'gz' : 'zip');
|
|
114
|
+
const outDir = path.resolve(args.out || path.join(process.cwd(), 'dist'));
|
|
115
|
+
const file = pack(dir, check.kind, format, outDir);
|
|
116
|
+
console.log(`[ext] ✓ ${path.basename(file)} (${(fs.statSync(file).size / 1024).toFixed(1)} KB)`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── ext dev ───
|
|
120
|
+
async function cmdDev(args) {
|
|
121
|
+
const srcDir = path.resolve(args._[1] || '.');
|
|
122
|
+
const site = args.site || process.env.EXT_SITE || 'http://localhost:3000';
|
|
123
|
+
let token = args.token || process.env.EXT_DEV_TOKEN;
|
|
124
|
+
// 未显式给 token 时,尝试从主项目 .env 读取(向上找两级)
|
|
125
|
+
if (!token) {
|
|
126
|
+
for (const dir of [process.cwd(), path.dirname(process.cwd())]) {
|
|
127
|
+
const envFile = path.join(dir, '.env');
|
|
128
|
+
if (!fs.existsSync(envFile)) continue;
|
|
129
|
+
const m = fs.readFileSync(envFile, 'utf-8').match(/^EXT_DEV_TOKEN\s*=\s*"?([^"\r\n]+)"?/m);
|
|
130
|
+
if (m) { token = m[1].trim(); break; }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!token) fail('缺少管理 token:--token 或环境变量 EXT_DEV_TOKEN(见主项目 .env)');
|
|
134
|
+
|
|
135
|
+
const check = validateManifest(srcDir);
|
|
136
|
+
if (check.errors.length) fail(check.errors.join('; '));
|
|
137
|
+
const slug = check.manifest.slug;
|
|
138
|
+
console.log(`[ext] dev 模式: ${slug} → ${site}`);
|
|
139
|
+
|
|
140
|
+
let timer = null;
|
|
141
|
+
const upload = async (reason) => {
|
|
142
|
+
const tmp = path.join(process.cwd(), '.ext-dev');
|
|
143
|
+
const check2 = validateManifest(srcDir);
|
|
144
|
+
if (check2.errors.length) { console.error(`[ext] ✗ 编译失败: ${check2.errors.join('; ')}`); return; }
|
|
145
|
+
buildOne(srcDir, tmp);
|
|
146
|
+
const file = pack(tmp, check2.kind, 'zip', tmp);
|
|
147
|
+
try {
|
|
148
|
+
const form = new FormData();
|
|
149
|
+
form.append('file', new Blob([fs.readFileSync(file)]), path.basename(file));
|
|
150
|
+
const res = await fetch(`${site}/api/console/extensions/dev-install`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'x-ext-token': token },
|
|
153
|
+
body: form,
|
|
154
|
+
});
|
|
155
|
+
const body = await res.json().catch(() => ({}));
|
|
156
|
+
if (res.ok) console.log(`[ext] ✓ 已热更新 ${slug}(${reason})`);
|
|
157
|
+
else console.error(`[ext] ✗ 上传失败 ${res.status}: ${JSON.stringify(body)}`);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(`[ext] ✗ 无法连接站点: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
await upload('首次构建');
|
|
164
|
+
console.log('[ext] 监听源码变更中… Ctrl+C 退出');
|
|
165
|
+
fs.watch(srcDir, { recursive: true }, () => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
timer = setTimeout(() => upload('源码变更'), 500);
|
|
168
|
+
});
|
|
169
|
+
// 保持进程
|
|
170
|
+
setInterval(() => {}, 1 << 30);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── main ───
|
|
174
|
+
async function main() {
|
|
175
|
+
const args = parseArgs(process.argv.slice(2));
|
|
176
|
+
const cmd = args._[0];
|
|
177
|
+
try {
|
|
178
|
+
if (cmd === 'new') return cmdNew(args);
|
|
179
|
+
if (cmd === 'build') return cmdBuild(args);
|
|
180
|
+
if (cmd === 'build-all') return cmdBuildAll(args);
|
|
181
|
+
if (cmd === 'pack') return cmdPack(args);
|
|
182
|
+
if (cmd === 'dev') return await cmdDev(args);
|
|
183
|
+
console.log(`RainyNest 扩展 CLI
|
|
184
|
+
|
|
185
|
+
用法:
|
|
186
|
+
ext new plugin|theme <名称> 生成模板工程
|
|
187
|
+
ext build <srcDir> [-o dist] 编译为安装态产物
|
|
188
|
+
ext build-all --src packages --out scripts 批量编译
|
|
189
|
+
ext pack <builtDir> [--format zip|gz] 打包发布产物
|
|
190
|
+
ext dev <srcDir> [--site url] watch 热更新`);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
fail(err.message);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
main();
|
package/lib/bundle.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// tools/ext-cli/lib/bundle.js — esbuild 编译:插件 index.ts/admin.tsx、主题 theme.ts
|
|
2
|
+
// bundle: true → 依赖全打进产物(自包含);react/next/@prisma/client 由宿主提供
|
|
3
|
+
const esbuild = require('esbuild');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
// 宿主提供的运行时模块,禁止打进包里
|
|
8
|
+
const EXTERNALS = ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', 'next', 'next/*', '@prisma/client', '@rainynest/plugin-types', 'better-sqlite3'];
|
|
9
|
+
|
|
10
|
+
const BASE = {
|
|
11
|
+
format: 'cjs',
|
|
12
|
+
platform: 'node',
|
|
13
|
+
target: 'node20',
|
|
14
|
+
bundle: true,
|
|
15
|
+
// 统一 automatic:index.ts 动态 import('./admin.js') 会按 TS 规则解析到 admin.tsx,
|
|
16
|
+
// classic JSX 在无 React 导入的产物里运行时会报 React is not defined
|
|
17
|
+
jsx: 'automatic',
|
|
18
|
+
minify: process.env.EXT_MINIFY === '1',
|
|
19
|
+
sourcemap: false,
|
|
20
|
+
logLevel: 'silent',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const STATIC_FILES = [
|
|
24
|
+
'plugin.json', 'config.json', 'theme.json', 'theme.css', 'preview.png', 'preview.jpg',
|
|
25
|
+
'README.md', 'LICENSE',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// 编译单个扩展源码目录 → 安装态产物目录(index.js 与 admin.js 同目录,import('./admin.js') 才能解析)
|
|
29
|
+
function buildOne(srcDir, outDir) {
|
|
30
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
31
|
+
const built = [];
|
|
32
|
+
|
|
33
|
+
const compile = (entry, outfile) => {
|
|
34
|
+
if (!fs.existsSync(entry)) return;
|
|
35
|
+
esbuild.buildSync({
|
|
36
|
+
entryPoints: [entry],
|
|
37
|
+
outfile,
|
|
38
|
+
external: EXTERNALS,
|
|
39
|
+
...BASE,
|
|
40
|
+
});
|
|
41
|
+
built.push(path.basename(outfile));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const pick = (name) => {
|
|
45
|
+
// 兼容根目录与 src/ 两种源码布局
|
|
46
|
+
for (const p of [path.join(srcDir, name), path.join(srcDir, 'src', name)]) {
|
|
47
|
+
if (fs.existsSync(p)) return p;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 插件入口
|
|
53
|
+
const entry = pick('index.ts') || pick('index.js');
|
|
54
|
+
if (entry) {
|
|
55
|
+
compile(entry, path.join(outDir, 'index.js'));
|
|
56
|
+
if (!fs.existsSync(path.join(outDir, 'index.js'))) {
|
|
57
|
+
fs.copyFileSync(entry, path.join(outDir, 'index.js'));
|
|
58
|
+
built.push('index.js');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 后台页面
|
|
63
|
+
const admin = pick('admin.tsx');
|
|
64
|
+
if (admin) compile(admin, path.join(outDir, 'admin.js'));
|
|
65
|
+
|
|
66
|
+
// 主题配置映射
|
|
67
|
+
const theme = pick('theme.ts');
|
|
68
|
+
if (theme) compile(theme, path.join(outDir, 'theme.js'));
|
|
69
|
+
|
|
70
|
+
// 静态资源原样复制
|
|
71
|
+
for (const name of STATIC_FILES) {
|
|
72
|
+
const src = path.join(srcDir, name);
|
|
73
|
+
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(outDir, name));
|
|
74
|
+
}
|
|
75
|
+
copyDir(path.join(srcDir, 'assets'), path.join(outDir, 'assets'));
|
|
76
|
+
copyDir(path.join(srcDir, 'migrations'), path.join(outDir, 'migrations'));
|
|
77
|
+
copyDir(path.join(srcDir, 'templates'), path.join(outDir, 'templates'));
|
|
78
|
+
|
|
79
|
+
return built;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function copyDir(src, dest) {
|
|
83
|
+
if (!fs.existsSync(src)) return;
|
|
84
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
85
|
+
for (const item of fs.readdirSync(src)) {
|
|
86
|
+
const s = path.join(src, item);
|
|
87
|
+
if (fs.statSync(s).isDirectory()) copyDir(s, path.join(dest, item));
|
|
88
|
+
else fs.copyFileSync(s, path.join(dest, item));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { buildOne, EXTERNALS };
|
package/lib/pack.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// tools/ext-cli/lib/pack.js — 打包为发布产物
|
|
2
|
+
// 命名规则:{slug}.zip.rainy-plugin / {slug}.gz.rainy-theme(格式标记在类型后缀之前)
|
|
3
|
+
const AdmZip = require('adm-zip');
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const KIND_EXT = { plugin: 'rainy-plugin', theme: 'rainy-theme' };
|
|
9
|
+
|
|
10
|
+
// 与主项目 dev-install 白名单一致:发布包只装产物,不装源码
|
|
11
|
+
const ALLOWED = new Set(['.js', '.json', '.css', '.png', '.jpg', '.jpeg', '.svg', '.webp', '.sql', '.md', '.txt', '.woff', '.woff2', '.ttf']);
|
|
12
|
+
const SKIP_DIRS = new Set(['src', 'node_modules', 'dist']);
|
|
13
|
+
|
|
14
|
+
function collectFiles(dir, base = dir) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const item of fs.readdirSync(dir)) {
|
|
17
|
+
const full = path.join(dir, item);
|
|
18
|
+
if (fs.statSync(full).isDirectory()) {
|
|
19
|
+
if (SKIP_DIRS.has(item)) continue;
|
|
20
|
+
out.push(...collectFiles(full, base));
|
|
21
|
+
} else if (ALLOWED.has(path.extname(item).toLowerCase())) {
|
|
22
|
+
out.push(path.relative(base, full));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// dir: 安装态产物目录;kind: plugin|theme;format: zip|gz;outDir: 产物输出目录
|
|
29
|
+
function pack(dir, kind, format, outDir) {
|
|
30
|
+
const slug = require('./validate').validateManifest(dir).manifest?.slug;
|
|
31
|
+
if (!slug) throw new Error('打包失败:清单无效或缺少 slug');
|
|
32
|
+
|
|
33
|
+
const name = `${slug}.${format}.${KIND_EXT[kind]}`;
|
|
34
|
+
const outFile = path.join(outDir, name);
|
|
35
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
if (format === 'zip') {
|
|
38
|
+
const zip = new AdmZip();
|
|
39
|
+
// 包内条目不带顶层目录,解压即清单所在目录
|
|
40
|
+
for (const rel of collectFiles(dir)) {
|
|
41
|
+
zip.addLocalFile(path.join(dir, rel), path.dirname(rel) === '.' ? '' : path.dirname(rel));
|
|
42
|
+
}
|
|
43
|
+
zip.writeZip(outFile);
|
|
44
|
+
} else {
|
|
45
|
+
// gz = tar.gz,用系统 tar(Windows 10+ 自带 bsdtar);先收集白名单文件避免打包源码
|
|
46
|
+
const tmp = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'ext-pack-'));
|
|
47
|
+
try {
|
|
48
|
+
for (const rel of collectFiles(dir)) {
|
|
49
|
+
const target = path.join(tmp, rel);
|
|
50
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
51
|
+
fs.copyFileSync(path.join(dir, rel), target);
|
|
52
|
+
}
|
|
53
|
+
execFileSync('tar', ['-czf', outFile, '-C', tmp, '.'], { stdio: 'pipe' });
|
|
54
|
+
} finally {
|
|
55
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return outFile;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { pack, KIND_EXT };
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// tools/ext-cli/lib/validate.js — 清单校验(specVersion 1.0)
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
6
|
+
const SPEC_VERSION = '1.0';
|
|
7
|
+
|
|
8
|
+
// 校验清单,返回 { kind: 'plugin'|'theme', manifest, errors, warnings }
|
|
9
|
+
function validateManifest(dir) {
|
|
10
|
+
const errors = [];
|
|
11
|
+
const warnings = [];
|
|
12
|
+
|
|
13
|
+
const pluginPath = path.join(dir, 'plugin.json');
|
|
14
|
+
const themePath = path.join(dir, 'theme.json');
|
|
15
|
+
let kind = null;
|
|
16
|
+
let manifest = null;
|
|
17
|
+
|
|
18
|
+
if (fs.existsSync(pluginPath)) {
|
|
19
|
+
kind = 'plugin';
|
|
20
|
+
manifest = readJson(pluginPath, errors);
|
|
21
|
+
} else if (fs.existsSync(themePath)) {
|
|
22
|
+
kind = 'theme';
|
|
23
|
+
manifest = readJson(themePath, errors);
|
|
24
|
+
} else {
|
|
25
|
+
return { kind: null, manifest: null, errors: ['缺少 plugin.json 或 theme.json'], warnings };
|
|
26
|
+
}
|
|
27
|
+
if (!manifest) return { kind, manifest: null, errors, warnings };
|
|
28
|
+
|
|
29
|
+
const entryName = kind === 'plugin' ? 'plugin.json' : 'theme.json';
|
|
30
|
+
|
|
31
|
+
// 必填字段
|
|
32
|
+
for (const field of ['slug', 'name', 'version']) {
|
|
33
|
+
if (!manifest[field]) errors.push(`${entryName}: 缺少必填字段 ${field}`);
|
|
34
|
+
}
|
|
35
|
+
if (manifest.slug && !SLUG_RE.test(manifest.slug)) {
|
|
36
|
+
errors.push(`${entryName}: slug 只允许小写字母/数字/连字符(${manifest.slug})`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// specVersion
|
|
40
|
+
if (!manifest.specVersion) {
|
|
41
|
+
warnings.push(`${entryName}: 缺少 specVersion,按 1.0 处理`);
|
|
42
|
+
manifest.specVersion = SPEC_VERSION;
|
|
43
|
+
} else if (manifest.specVersion !== SPEC_VERSION) {
|
|
44
|
+
errors.push(`${entryName}: 不支持的 specVersion ${manifest.specVersion}(宿主支持 ${SPEC_VERSION})`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 入口文件存在性(校验源码目录或产物目录都行)
|
|
48
|
+
if (kind === 'plugin') {
|
|
49
|
+
const entry = manifest.entry || 'index.ts';
|
|
50
|
+
const inRoot = (n) => fs.existsSync(path.join(dir, n)) || fs.existsSync(path.join(dir, 'src', n));
|
|
51
|
+
if (!inRoot(entry) && !inRoot(entry.replace(/\.tsx?$/, '.js'))) errors.push(`插件入口不存在: ${entry}`);
|
|
52
|
+
} else {
|
|
53
|
+
const hasThemeJs = fs.existsSync(path.join(dir, 'theme.js')) || fs.existsSync(path.join(dir, 'theme.ts'));
|
|
54
|
+
if (!hasThemeJs && manifest.configSchema && Object.keys(manifest.configSchema).length > 0) {
|
|
55
|
+
warnings.push('theme.js/theme.ts 不存在,配置映射将不生效');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { kind, manifest, errors, warnings };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 产物目录安全扫描:禁止原生模块
|
|
63
|
+
function scanArtifacts(dir) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
walk(dir, (file) => {
|
|
66
|
+
if (file.endsWith('.node')) errors.push(`产物包含原生模块: ${path.relative(dir, file)}`);
|
|
67
|
+
});
|
|
68
|
+
return errors;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function walk(dir, fn) {
|
|
72
|
+
if (!fs.existsSync(dir)) return;
|
|
73
|
+
for (const item of fs.readdirSync(dir)) {
|
|
74
|
+
const full = path.join(dir, item);
|
|
75
|
+
if (fs.statSync(full).isDirectory()) walk(full, fn);
|
|
76
|
+
else fn(full);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readJson(file, errors) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
errors.push(`${path.basename(file)} 解析失败: ${err.message}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { validateManifest, scanArtifacts, SPEC_VERSION, SLUG_RE };
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rainynest/ext",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "RainyNest 插件/主题开发 CLI:new / build / build-all / pack / dev",
|
|
5
|
+
"bin": { "ext": "bin/ext.js" },
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"bin/",
|
|
9
|
+
"lib/",
|
|
10
|
+
"templates/"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"adm-zip": "^0.6.1",
|
|
14
|
+
"esbuild": "^0.25.0"
|
|
15
|
+
},
|
|
16
|
+
"engines": { "node": ">=20" },
|
|
17
|
+
"keywords": [
|
|
18
|
+
"rainynest",
|
|
19
|
+
"plugin",
|
|
20
|
+
"theme",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT"
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# __SLUG__
|
|
2
|
+
|
|
3
|
+
RainyNest 插件。
|
|
4
|
+
|
|
5
|
+
## 开发
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -D @rainynest/plugin-types typescript
|
|
9
|
+
npx @rainynest/ext dev . --site http://localhost:3000
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
需要主项目 `.env` 里的 `EXT_DEV_TOKEN`(放本目录 `.env` 亦可)。
|
|
13
|
+
|
|
14
|
+
## 发布
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @rainynest/ext build . -o dist
|
|
18
|
+
npx @rainynest/ext pack dist --format zip
|
|
19
|
+
# 产出 dist/__SLUG__.zip.rainy-plugin
|
|
20
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "__SLUG__",
|
|
3
|
+
"name": "__SLUG__",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "插件描述",
|
|
6
|
+
"author": "",
|
|
7
|
+
"specVersion": "1.0",
|
|
8
|
+
"requiresCore": ">=1.0.0",
|
|
9
|
+
"entry": "index.ts",
|
|
10
|
+
"permissions": [],
|
|
11
|
+
"roleTemplates": [],
|
|
12
|
+
"database": { "enabled": false },
|
|
13
|
+
"configSchema": {
|
|
14
|
+
"basic": {
|
|
15
|
+
"label": "基础",
|
|
16
|
+
"fields": {
|
|
17
|
+
"enabled": { "type": "switch", "label": "启用", "default": true }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/admin.tsx — 可选后台页面(customAdminRoutes 引用)
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
export default function AdminPage() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="space-y-6">
|
|
7
|
+
<h1 className="text-2xl font-bold">__SLUG__</h1>
|
|
8
|
+
<p className="text-sm text-gray-500">插件管理页</p>
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// src/index.ts — __SLUG__ 插件入口
|
|
2
|
+
import type { PluginContext, ExtensionHandlers } from '@rainynest/plugin-types';
|
|
3
|
+
|
|
4
|
+
export default function createPlugin(ctx: PluginContext): ExtensionHandlers {
|
|
5
|
+
ctx.logger.info('__SLUG__ 已加载,版本', ctx.version);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
// 在这里实现扩展点,例如:
|
|
9
|
+
// onMount(ctx) {},
|
|
10
|
+
// injectFooter() { return []; },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// src/theme.ts — 配置值 → CSS 变量
|
|
2
|
+
export function configToCssVariables(config: Record<string, any>): Record<string, string> {
|
|
3
|
+
const vars: Record<string, string> = {};
|
|
4
|
+
if (config?.basic?.accentColor) {
|
|
5
|
+
vars['--color-primary'] = config.basic.accentColor;
|
|
6
|
+
}
|
|
7
|
+
return vars;
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* __SLUG__ 主题样式 */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "__SLUG__",
|
|
3
|
+
"name": "__SLUG__",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "主题描述",
|
|
6
|
+
"author": "",
|
|
7
|
+
"specVersion": "1.0",
|
|
8
|
+
"minRainyNestVersion": ">=1.0.0",
|
|
9
|
+
"preview": "preview.png",
|
|
10
|
+
"supports": ["posts", "pages", "moments", "gallery", "links"],
|
|
11
|
+
"configSchema": {
|
|
12
|
+
"basic": {
|
|
13
|
+
"label": "基础",
|
|
14
|
+
"fields": {
|
|
15
|
+
"accentColor": { "type": "color", "label": "强调色", "default": "#3b82f6" }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"advanced": {
|
|
19
|
+
"label": "高级",
|
|
20
|
+
"fields": {
|
|
21
|
+
"customCss": { "type": "textarea", "label": "自定义 CSS", "default": "" },
|
|
22
|
+
"customJs": { "type": "textarea", "label": "自定义 JS", "default": "" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|