@aiti-ai/aiti-onlyoffice 0.0.0-stage → 0.1.7
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/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/blob-save-route.ts +128 -0
- package/check-client.mjs +273 -0
- package/client/src/entry.ts +98 -0
- package/client/src/oo-view.ts +119 -0
- package/client/src/package.json +1 -0
- package/client/src/token.test.mjs +69 -0
- package/client/src/token.ts +40 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/icon.svg +1 -0
- package/index.ts +210 -0
- package/package.json +31 -4
- package/reload-instances.mjs +60 -0
- package/scripts/onlyoffice-down.sh +6 -0
- package/scripts/onlyoffice-up.sh +41 -0
- package/token.ts +40 -0
- package/tools/deploy.mjs +99 -0
- package/vite.config.ts +29 -0
- package/README.md +0 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# aiti-onlyoffice 扩展(部署目录直改指南)
|
|
2
|
+
|
|
3
|
+
## 结构
|
|
4
|
+
- 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
|
|
5
|
+
- `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
|
|
6
|
+
- npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
|
|
7
|
+
|
|
8
|
+
## 小改现场流程(改文案/样式/小逻辑)
|
|
9
|
+
1. 改 `src/` 下对应源码
|
|
10
|
+
2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
|
|
11
|
+
3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
|
|
12
|
+
4. 浏览器刷新页面验证
|
|
13
|
+
|
|
14
|
+
## 大改回源码仓
|
|
15
|
+
- 源码仓:`extensions/aiti-onlyoffice/`(git 管理,deploy 同步回本目录)
|
|
16
|
+
- 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-onlyoffice`)
|
|
17
|
+
|
|
18
|
+
## 数据目录(数据分层:config / cache)
|
|
19
|
+
- `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
|
|
20
|
+
- `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
|
|
21
|
+
- `ctx.dataDir` 已移除(访问即抛)——新代码禁用
|
|
22
|
+
- 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
|
|
23
|
+
|
|
24
|
+
## 红线
|
|
25
|
+
- 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
|
|
26
|
+
- 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
|
|
27
|
+
- 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
|
|
28
|
+
- 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiti-ai contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blob-save-route —— 二进制文件保存 route 工厂(共享权威源,部署时拷进扩展目录)。
|
|
3
|
+
*
|
|
4
|
+
* 消费方:aiti-sheet-univer / aiti-word-superdoc 等带 edit 能力的实现扩展
|
|
5
|
+
* (docs/aiti-editor-office-plan.md §2.0 共享层)。
|
|
6
|
+
*
|
|
7
|
+
* 端点语义(POST,body JSON):
|
|
8
|
+
* { filePath: string, dataBase64: string, baseMtimeMs?: number, cwd?: string }
|
|
9
|
+
* → 200 { ok: true, mtimeMs, size }
|
|
10
|
+
* → 400 路径不在白名单 / 扩展名不在白名单 / dataBase64 非法
|
|
11
|
+
* → 409 { ok:false, conflict:true, diskMtimeMs } 磁盘已被外部修改(乐观锁)
|
|
12
|
+
* → 413 超过 maxBytes
|
|
13
|
+
* 写盘原子(tmp + rename),目标目录自动创建(同名文件保存必然已存在,目录防御性兜底)。
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** ~ 展开(浏览器无 homedir;对齐 core /api/files filePathFromSegments) */
|
|
21
|
+
function expandTildePath(p: string): string {
|
|
22
|
+
const home = homedir();
|
|
23
|
+
if (p === "~") return home;
|
|
24
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return join(home, p.slice(2));
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 保存白名单(对标 aiti-editor M1 宽松版:workspace + 用户根) */
|
|
29
|
+
export function resolveAllowedWriteRoot(filePath: string, cwd?: string): string | null {
|
|
30
|
+
if (!filePath) return null;
|
|
31
|
+
const expanded = expandTildePath(filePath);
|
|
32
|
+
if (!isAbsolute(expanded)) return null;
|
|
33
|
+
const home = homedir();
|
|
34
|
+
const roots: string[] = [];
|
|
35
|
+
if (cwd && isAbsolute(cwd)) roots.push(normalize(cwd));
|
|
36
|
+
roots.push(join(home, "workspace"));
|
|
37
|
+
roots.push(home);
|
|
38
|
+
const fp = normalize(expanded);
|
|
39
|
+
return roots.some((r) => fp === r || fp.startsWith(r + sep)) ? fp : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BlobSaveRouteOptions {
|
|
43
|
+
/** 扩展 id(tmp 文件命名/日志前缀) */
|
|
44
|
+
extId: string;
|
|
45
|
+
/** 允许保存的扩展名白名单(小写含点,如 [".xlsx", ".xlsm"]) */
|
|
46
|
+
allowedExts: string[];
|
|
47
|
+
/** 单文件字节上限(默认 30MB,方案 §2.1 maxBytes feature 档) */
|
|
48
|
+
maxBytes?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BlobSaveBody {
|
|
52
|
+
filePath?: unknown;
|
|
53
|
+
dataBase64?: unknown;
|
|
54
|
+
baseMtimeMs?: unknown;
|
|
55
|
+
cwd?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 生成 route handler:`if (p[p.length-1] === "save-blob") return await handle(req);`
|
|
60
|
+
*/
|
|
61
|
+
export function createBlobSaveRoute(opts: BlobSaveRouteOptions) {
|
|
62
|
+
const maxBytes = opts.maxBytes ?? 30 * 1024 * 1024;
|
|
63
|
+
const allowed = new Set(opts.allowedExts.map((e) => e.toLowerCase()));
|
|
64
|
+
const log = (m: string) => console.log("[" + opts.extId + "][save-blob] " + m);
|
|
65
|
+
|
|
66
|
+
return async function handleSaveBlob(req: Request): Promise<Response> {
|
|
67
|
+
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
|
|
68
|
+
status,
|
|
69
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
70
|
+
});
|
|
71
|
+
let body: BlobSaveBody;
|
|
72
|
+
try {
|
|
73
|
+
body = await req.json();
|
|
74
|
+
} catch {
|
|
75
|
+
return json({ ok: false, error: "body 需为 JSON" }, 400);
|
|
76
|
+
}
|
|
77
|
+
const filePath = typeof body.filePath === "string" ? body.filePath : "";
|
|
78
|
+
const dataBase64 = typeof body.dataBase64 === "string" ? body.dataBase64 : "";
|
|
79
|
+
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
|
|
80
|
+
if (!filePath || !dataBase64) return json({ ok: false, error: "需要 filePath 与 dataBase64" }, 400);
|
|
81
|
+
|
|
82
|
+
const lower = filePath.toLowerCase();
|
|
83
|
+
if (![...allowed].some((ext) => lower.endsWith(ext))) {
|
|
84
|
+
return json({ ok: false, error: "扩展名不在白名单内(" + [...allowed].join(" ") + ")" }, 400);
|
|
85
|
+
}
|
|
86
|
+
const target = resolveAllowedWriteRoot(filePath, cwd);
|
|
87
|
+
if (!target) return json({ ok: false, error: "路径不在可写白名单内" }, 400);
|
|
88
|
+
|
|
89
|
+
let buf: Buffer;
|
|
90
|
+
try {
|
|
91
|
+
buf = Buffer.from(dataBase64, "base64");
|
|
92
|
+
} catch {
|
|
93
|
+
return json({ ok: false, error: "dataBase64 非法" }, 400);
|
|
94
|
+
}
|
|
95
|
+
if (buf.length === 0) return json({ ok: false, error: "空内容拒绝保存" }, 400);
|
|
96
|
+
if (buf.length > maxBytes) return json({ ok: false, error: "文件超过 " + Math.round(maxBytes / 1024 / 1024) + "MB 上限" }, 413);
|
|
97
|
+
|
|
98
|
+
// 乐观锁:baseMtimeMs 与磁盘不一致 → 409(沿用 aiti-editor 冲突 UI 范式)
|
|
99
|
+
let st;
|
|
100
|
+
try { st = statSync(target); } catch { /* 新文件(理论上保存目标必已存在;防御) */ }
|
|
101
|
+
if (st) {
|
|
102
|
+
if (!st.isFile()) return json({ ok: false, error: "目标不是文件" }, 400);
|
|
103
|
+
if (typeof body.baseMtimeMs === "number" && Math.abs(st.mtimeMs - (body.baseMtimeMs as number)) > 1) {
|
|
104
|
+
return json({ ok: false, conflict: true, diskMtimeMs: st.mtimeMs }, 409);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 原子写:tmp 必须与目标同目录(同文件系统,rename 才不跨设备 EXDEV)
|
|
109
|
+
const tmp = join(dirname(target), "." + opts.extId + "-save-tmp-" + process.pid + "-" + Date.now());
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
112
|
+
writeFileSync(tmp, buf);
|
|
113
|
+
renameSync(tmp, target);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
try { require("node:fs").rmSync(tmp, { force: true }); } catch { /* 忽略 */ }
|
|
116
|
+
log("写盘失败: " + String((e as Error).message || e));
|
|
117
|
+
return json({ ok: false, error: "写盘失败: " + String((e as Error).message || e) }, 500);
|
|
118
|
+
}
|
|
119
|
+
const after = statSync(target);
|
|
120
|
+
log("已保存 " + target + " (" + after.size + " bytes)");
|
|
121
|
+
return json({ ok: true, mtimeMs: after.mtimeMs, size: after.size });
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 内容哈希(调试/日志用) */
|
|
126
|
+
export function blobHash(buf: Buffer): string {
|
|
127
|
+
return createHash("md5").update(buf).digest("hex").slice(0, 8);
|
|
128
|
+
}
|
package/check-client.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-client —— 扩展 client .mjs/.js 的 TS 残留 tripwire(零依赖,纯 node)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:aiti-plan-review 事故(docs/aiti-ext-client-hardening-plan.md §1)——
|
|
5
|
+
* `useState<any>(null)` 解析为合法 JS 比较表达式 `useState < any > (null)`,
|
|
6
|
+
* node --check / TS 解析器 / babel 全不报错,只有运行时才炸。纯语法检查
|
|
7
|
+
* 原理性无效,只能启发式规则 + 渲染期错误边界兜底。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* CLI node check-client.mjs <file|dir>... FAIL → exit 1
|
|
11
|
+
* 自测 node check-client.mjs --selftest fixtures 全过 → exit 0
|
|
12
|
+
* API lintClientSource(src) → violations[] { line, level, rule, text }
|
|
13
|
+
* lintPath(path) → { files, violations }(文件或目录,递归)
|
|
14
|
+
* checkClientDir(dir,{log}) FAIL → throw(deploy 脚本用)
|
|
15
|
+
*
|
|
16
|
+
* 词法:规则匹配前先剥离注释/字符串/模板字面量(${} 插值内代码保留检查)——
|
|
17
|
+
* aiti-editor 的 Monaco TS stub 模板串、JSDoc @return 都是合法内容,不剥全误报。
|
|
18
|
+
* 已知极限:regex 字面量含引号等 lexer 歧义允许漏报(tripwire 定位非编译器)。
|
|
19
|
+
*
|
|
20
|
+
* 豁免:目标行或其上一行含 `check-client:allow` 即跳过该行。
|
|
21
|
+
* 权威源:本文件;aiti-extman 部署副本由其 tools/deploy.mjs 拷贝(改动两处同步)。
|
|
22
|
+
*/
|
|
23
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
// ── 规则(分级)──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const RULES = [
|
|
29
|
+
// FAIL:合法 JS 但运行时必炸 / 纯 TS 语法
|
|
30
|
+
{ level: "FAIL", re: /\b(useState|useRef|useMemo|useCallback|useReducer|useImperativeHandle)\s*</, why: "React hook 泛型(运行时必炸)" },
|
|
31
|
+
// 注:含「any/unknown/string」字样的规则用字符串构 RegExp——本文件自身也
|
|
32
|
+
// 会被扫,正则字面量里的 (?:any 会被自己命中(词法器不处理 regex 字面量),
|
|
33
|
+
// 字符串字面量则会被词法遮蔽。
|
|
34
|
+
{ level: "FAIL", re: new RegExp("\\bas\\s+(?:" + "any|unknown|const)\\b"), why: "as 断言" },
|
|
35
|
+
{ level: "FAIL", re: new RegExp(":\\s*(?:" + "any|unknown)\\b"), why: ": any/: unknown 注解" },
|
|
36
|
+
{ level: "FAIL", re: /\b(?:import|export)\s+type\s/, why: "import/export type" },
|
|
37
|
+
{ level: "FAIL", re: /\b(?:interface|enum)\s+[A-Za-z_$]/, why: "interface/enum 声明" },
|
|
38
|
+
// warn:疑似残留,人工确认,不阻断
|
|
39
|
+
{ level: "warn", re: new RegExp(":\\s*(?:" + "string|number|boolean|void)\\b(?!\\s*[._\\w])"), why: "疑似类型注解" },
|
|
40
|
+
{ level: "warn", re: /\bsatisfies\s/, why: "satisfies" },
|
|
41
|
+
{ level: "warn", re: /\b(?:Record|Partial|Readonly|Omit|Pick)\s*</, why: "TS 工具类型" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// ── 词法剥离:注释/字符串/模板字面量 → 空格(保留换行,行号不漂移)─────────
|
|
45
|
+
export function stripLiterals(src) {
|
|
46
|
+
const out = src.split("");
|
|
47
|
+
const n = src.length;
|
|
48
|
+
const mask = (from, to) => {
|
|
49
|
+
for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " ";
|
|
50
|
+
};
|
|
51
|
+
let mode = "code";
|
|
52
|
+
let depth = 0;
|
|
53
|
+
const stack = []; // 模板 ${ 插值进入时的 brace 深度(支持嵌套模板)
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < n) {
|
|
56
|
+
const c = src[i];
|
|
57
|
+
const d = i + 1 < n ? src[i + 1] : "";
|
|
58
|
+
if (mode === "code") {
|
|
59
|
+
if (c === "/" && d === "/") {
|
|
60
|
+
const j = src.indexOf("\n", i);
|
|
61
|
+
const end = j === -1 ? n : j;
|
|
62
|
+
mask(i, end);
|
|
63
|
+
i = end;
|
|
64
|
+
} else if (c === "/" && d === "*") {
|
|
65
|
+
const j = src.indexOf("*/", i + 2);
|
|
66
|
+
const end = j === -1 ? n : j + 2;
|
|
67
|
+
mask(i, end);
|
|
68
|
+
i = end;
|
|
69
|
+
} else if (c === "'" || c === '"') {
|
|
70
|
+
let j = i + 1;
|
|
71
|
+
while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
|
|
72
|
+
if (j < n && src[j] === c) {
|
|
73
|
+
mask(i, j + 1);
|
|
74
|
+
i = j + 1;
|
|
75
|
+
} else {
|
|
76
|
+
// 未闭合到行尾 → 可能是 regex 字面量歧义,只遮开引号,按代码继续
|
|
77
|
+
out[i] = " ";
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
} else if (c === "`") {
|
|
81
|
+
mode = "tpl";
|
|
82
|
+
out[i] = " ";
|
|
83
|
+
i++;
|
|
84
|
+
} else if (c === "{") {
|
|
85
|
+
depth++;
|
|
86
|
+
i++;
|
|
87
|
+
} else if (c === "}") {
|
|
88
|
+
if (stack.length && depth === stack[stack.length - 1]) {
|
|
89
|
+
stack.pop();
|
|
90
|
+
mode = "tpl"; // 插值结束回模板
|
|
91
|
+
out[i] = " ";
|
|
92
|
+
} else {
|
|
93
|
+
depth = Math.max(0, depth - 1);
|
|
94
|
+
}
|
|
95
|
+
i++;
|
|
96
|
+
} else i++;
|
|
97
|
+
} else if (mode === "tpl") {
|
|
98
|
+
if (c === "\\") {
|
|
99
|
+
mask(i, Math.min(i + 2, n));
|
|
100
|
+
i += 2;
|
|
101
|
+
} else if (c === "`") {
|
|
102
|
+
mode = "code";
|
|
103
|
+
out[i] = " ";
|
|
104
|
+
i++;
|
|
105
|
+
} else if (c === "$" && d === "{") {
|
|
106
|
+
stack.push(depth);
|
|
107
|
+
mode = "code";
|
|
108
|
+
mask(i, i + 2);
|
|
109
|
+
i += 2;
|
|
110
|
+
} else {
|
|
111
|
+
if (c !== "\n") out[i] = " ";
|
|
112
|
+
i++;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// 不可达:line/block/字符串均在 code 分支内联处理
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out.join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 单源检查 ────────────────────────────────────────────────────────────────
|
|
123
|
+
export function lintClientSource(src) {
|
|
124
|
+
const masked = stripLiterals(src);
|
|
125
|
+
const origLines = src.split("\n");
|
|
126
|
+
const maskedLines = masked.split("\n");
|
|
127
|
+
const violations = [];
|
|
128
|
+
for (let i = 0; i < maskedLines.length; i++) {
|
|
129
|
+
const prev = i > 0 ? origLines[i - 1] : "";
|
|
130
|
+
if (origLines[i].includes("check-client:allow") || prev.includes("check-client:allow")) continue;
|
|
131
|
+
for (const { level, re, why } of RULES) {
|
|
132
|
+
if (re.test(maskedLines[i])) {
|
|
133
|
+
violations.push({ line: i + 1, level, rule: why, text: origLines[i].trim().slice(0, 90) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return violations;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 文件/目录检查 ───────────────────────────────────────────────────────────
|
|
141
|
+
const SKIP_DIR = new Set(["node_modules", ".git"]);
|
|
142
|
+
/** 超过此体积视为 vendored bundle(如 Monaco ts.worker,合法含 interface 字样),不查。 */
|
|
143
|
+
const MAX_BYTES = 512 * 1024;
|
|
144
|
+
const isClientFile = (name) => /\.(mjs|js|cjs)$/.test(name) && !/\.min\./.test(name) && !/\.test\./.test(name);
|
|
145
|
+
|
|
146
|
+
export function lintPath(p, out = { files: 0, violations: [] }) {
|
|
147
|
+
const st = statSync(p);
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
for (const name of readdirSync(p)) {
|
|
150
|
+
if (SKIP_DIR.has(name)) continue;
|
|
151
|
+
lintPath(join(p, name), out);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
const base = p.split("/").pop();
|
|
156
|
+
if (!isClientFile(base)) return out;
|
|
157
|
+
if (st.size > MAX_BYTES) return out; // vendored bundle 豁免
|
|
158
|
+
out.files++;
|
|
159
|
+
for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function formatViolations(violations) {
|
|
164
|
+
return violations
|
|
165
|
+
.map((v) => ` ${v.level} ${v.file}:${v.line} [${v.rule}] ${v.text}`)
|
|
166
|
+
.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** deploy 脚本用:检查目录,FAIL 抛错(消息含违规清单),warn 只打日志不阻断。 */
|
|
170
|
+
export function checkClientDir(dir, { log = console.log } = {}) {
|
|
171
|
+
const { files, violations } = lintPath(resolve(dir));
|
|
172
|
+
const fails = violations.filter((v) => v.level === "FAIL");
|
|
173
|
+
const warns = violations.filter((v) => v.level === "warn");
|
|
174
|
+
if (warns.length) log(`[check-client] ${warns.length} 处疑似 TS 残留(不阻断,建议人工确认):\n${formatViolations(warns)}`);
|
|
175
|
+
if (fails.length) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`client 检查未通过:${files} 个文件中 ${fails.length} 处 TS 残留(.mjs 里是合法 JS,语法检查不报,运行时必炸)。\n` +
|
|
178
|
+
formatViolations(fails) +
|
|
179
|
+
`\n修复或在该行加注释 // check-client:allow 豁免。详见 docs/aiti-ext-client-hardening-plan.md`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (files) log(`[check-client] ${files} 个 client 文件检查通过`);
|
|
183
|
+
return { files, fails: fails.length, warns: warns.length };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* extman 安装用:扫整个扩展目录(含第三方,不遵循 client/ 约定)。
|
|
188
|
+
* 在 SKIP_DIR 之上额外跳过构建产物/vendored 目录(Monaco ts.worker、语言包等
|
|
189
|
+
* 合法包含 TS 字样,词法器对 minified 代码不提供保证);体积上限同上。
|
|
190
|
+
*/
|
|
191
|
+
export function lintExtensionDir(dir) {
|
|
192
|
+
const out = { files: 0, violations: [] };
|
|
193
|
+
const walk = (p) => {
|
|
194
|
+
const st = statSync(p);
|
|
195
|
+
if (st.isDirectory()) {
|
|
196
|
+
for (const name of readdirSync(p)) {
|
|
197
|
+
if (SKIP_DIR.has(name) || VENDORED_DIRS.has(name)) continue;
|
|
198
|
+
walk(join(p, name));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const base = p.split("/").pop();
|
|
203
|
+
if (!isClientFile(base) || st.size > MAX_BYTES) return;
|
|
204
|
+
out.files++;
|
|
205
|
+
for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
|
|
206
|
+
};
|
|
207
|
+
walk(resolve(dir));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
const VENDORED_DIRS = new Set(["vendor", "monaco", "dist", "build", "assets", "static", "bundles", "chunks"]);
|
|
211
|
+
|
|
212
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
213
|
+
function selftest() {
|
|
214
|
+
const MUST_FAIL = [
|
|
215
|
+
"const [st, setSt] = useState<any>(null);",
|
|
216
|
+
"const r = useRef<number>(null);",
|
|
217
|
+
"const x = v as any;",
|
|
218
|
+
"function f(a: unknown) {}",
|
|
219
|
+
'import type { Foo } from "./x";',
|
|
220
|
+
"interface Foo { a: string }",
|
|
221
|
+
"enum Color { Red }",
|
|
222
|
+
"const y = v as const;",
|
|
223
|
+
];
|
|
224
|
+
const MUST_WARN = [
|
|
225
|
+
"const opts = { mode: string };",
|
|
226
|
+
"x satisfies Y;",
|
|
227
|
+
"const m: Record<string, any> = {};",
|
|
228
|
+
];
|
|
229
|
+
const MUST_PASS = [
|
|
230
|
+
'const REACT_TYPE_STUB = `\ndeclare module "react" {\n export function useState<T>(init: T): [T];\n export const Fragment: any;\n}\n`;',
|
|
231
|
+
"/** @return {{chars1: string, chars2: string}} */",
|
|
232
|
+
"const [s] = useState(null);",
|
|
233
|
+
"useState<any>(null); // check-client:allow",
|
|
234
|
+
"// check-client:allow\nuseState<never>(0);",
|
|
235
|
+
"const cmp = a < b && c > (d);",
|
|
236
|
+
"if (a // 注释里 as any 不算\n ) run();",
|
|
237
|
+
];
|
|
238
|
+
let bad = 0;
|
|
239
|
+
const check = (src, expect, desc) => {
|
|
240
|
+
const v = lintClientSource(src);
|
|
241
|
+
const fails = v.filter((x) => x.level === "FAIL");
|
|
242
|
+
const ok = expect === "FAIL" ? fails.length > 0 : expect === "PASS" ? v.length === 0 : v.length > 0 && fails.length === 0;
|
|
243
|
+
if (!ok) {
|
|
244
|
+
bad++;
|
|
245
|
+
console.log(`SELFTEST FAIL [${desc}] 期望 ${expect},实际 ${JSON.stringify(v)}`);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
MUST_FAIL.forEach((s) => check(s, "FAIL", s.slice(0, 40)));
|
|
249
|
+
MUST_WARN.forEach((s) => check(s, "WARN", s.slice(0, 40)));
|
|
250
|
+
MUST_PASS.forEach((s) => check(s, "PASS", s.replace(/\n/g, "⏎").slice(0, 40)));
|
|
251
|
+
if (bad) {
|
|
252
|
+
console.log(`selftest: ${bad} 处未过`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
console.log("selftest: 全部通过(8 FAIL / 3 WARN / 7 PASS)");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
|
259
|
+
if (process.argv.includes("--selftest")) {
|
|
260
|
+
selftest();
|
|
261
|
+
} else if (process.argv.length > 2) {
|
|
262
|
+
const all = { files: 0, violations: [] };
|
|
263
|
+
for (const p of process.argv.slice(2)) lintPath(resolve(p), all);
|
|
264
|
+
const fails = all.violations.filter((v) => v.level === "FAIL");
|
|
265
|
+
const warns = all.violations.filter((v) => v.level === "warn");
|
|
266
|
+
if (all.violations.length) console.log(formatViolations(all.violations));
|
|
267
|
+
console.log(`\n${all.files} 个文件:FAIL=${fails.length} warn=${warns.length}`);
|
|
268
|
+
process.exit(fails.length ? 1 : 0);
|
|
269
|
+
} else {
|
|
270
|
+
console.log("用法:node check-client.mjs <file|dir>... | --selftest");
|
|
271
|
+
process.exit(2);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-onlyoffice · client 入口(M2,docs/aiti-editor-office-plan.md §2.4)。
|
|
3
|
+
*
|
|
4
|
+
* 注册判据(server /status):feature onlyoffice.enabled 开 + DocumentServer healthcheck 通过
|
|
5
|
+
* → 注册 word/sheet/slide 三 provider(分开注册让用户能分别设默认)。失败 → 不注册(菜单自然无此项)。
|
|
6
|
+
* 保存不经 ContentProvider(DS 内部经 callback 直写服务端),仅本次注册 editor provider。
|
|
7
|
+
*/
|
|
8
|
+
import { whenAitiEditorReady } from "../../../_shared/aiti-editor-inject.mjs";
|
|
9
|
+
import { renderOnlyofficeView } from "./oo-view";
|
|
10
|
+
|
|
11
|
+
interface ProviderSpec {
|
|
12
|
+
id: string;
|
|
13
|
+
displayName: string;
|
|
14
|
+
extensions: string[];
|
|
15
|
+
about: { summary: string; capabilities: string[]; limits: string[]; vendor: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PROVIDERS: ProviderSpec[] = [
|
|
19
|
+
{
|
|
20
|
+
id: "aiti-onlyoffice.docx",
|
|
21
|
+
displayName: "Word(OnlyOffice)",
|
|
22
|
+
extensions: [".docx", ".doc", ".odt"],
|
|
23
|
+
about: {
|
|
24
|
+
summary: "OnlyOffice DocumentServer 深度保真 Word 编辑(完整排版还原;.doc/.odt 老格式原生编辑)",
|
|
25
|
+
capabilities: ["完整保真渲染与编辑(与桌面 Word 同引擎)", "DS 侧保存(forcesave/自动保存经 callback 直写服务端)", "批注 / 修订 / 目录 / 文本框等复杂排版", "文件变化实时跟随(mtime → key 变 → DS 重拉)"],
|
|
26
|
+
limits: ["需要本机 Docker 运行 OnlyOffice DocumentServer(scripts/onlyoffice-up.sh)", "容器不可达时 provider 自动消失(菜单无此项)", "AGPL 社区版(隔离于本扩展,不装即无)"],
|
|
27
|
+
vendor: "OnlyOffice DocumentServer (AGPL-3.0 社区版,Docker)",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "aiti-onlyoffice.sheet",
|
|
32
|
+
displayName: "Excel(OnlyOffice)",
|
|
33
|
+
extensions: [".xlsx", ".xlsm", ".xls", ".ods"],
|
|
34
|
+
about: {
|
|
35
|
+
summary: "OnlyOffice DocumentServer 深度保真 Excel 编辑(.xls/.ods 老格式原生编辑)",
|
|
36
|
+
capabilities: ["完整保真渲染与编辑(公式 / 图表 / 透视表 / 条件格式)", "DS 侧保存(forcesave/自动保存经 callback 直写服务端)", "文件变化实时跟随"],
|
|
37
|
+
limits: ["需要本机 Docker 运行 OnlyOffice DocumentServer", "容器不可达时 provider 自动消失", "AGPL 社区版(隔离于本扩展)"],
|
|
38
|
+
vendor: "OnlyOffice DocumentServer (AGPL-3.0 社区版,Docker)",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "aiti-onlyoffice.slide",
|
|
43
|
+
displayName: "PowerPoint(OnlyOffice)",
|
|
44
|
+
extensions: [".pptx", ".ppt", ".odp"],
|
|
45
|
+
about: {
|
|
46
|
+
summary: "OnlyOffice DocumentServer 深度保真 PPT 编辑(.ppt/.odp 老格式原生编辑)",
|
|
47
|
+
capabilities: ["完整保真渲染与编辑(母版 / 动画占位 / 形状)", "DS 侧保存(forcesave/自动保存经 callback 直写服务端)"],
|
|
48
|
+
limits: ["需要本机 Docker 运行 OnlyOffice DocumentServer", "容器不可达时 provider 自动消失", "AGPL 社区版(隔离于本扩展)"],
|
|
49
|
+
vendor: "OnlyOffice DocumentServer (AGPL-3.0 社区版,Docker)",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
async function tryRegister(ctx: Record<string, any>): Promise<void> {
|
|
55
|
+
const apiUrl = (p: string) => (typeof ctx.apiUrl === "function" ? ctx.apiUrl(p) : p);
|
|
56
|
+
let status: any = null;
|
|
57
|
+
try {
|
|
58
|
+
status = await fetch(apiUrl("/api/extensions/aiti-onlyoffice/status"), { cache: "no-store" }).then((r) => r.json());
|
|
59
|
+
} catch { return; }
|
|
60
|
+
if (!status?.ok || !status.enabled || !status.available) {
|
|
61
|
+
console.log("[aiti-onlyoffice] 不注册 provider:" + (status?.message || "status 不可达"));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// dsPublicUrl 传给视图(api.js 加载地址)
|
|
65
|
+
(window as any).__aitiOnlyofficeDsPublicUrl = status.publicUrl || "";
|
|
66
|
+
|
|
67
|
+
whenAitiEditorReady(() => {
|
|
68
|
+
const we = (window as any).__aitiEditor;
|
|
69
|
+
const disposables: Array<() => void> = (window as any).__aitiOnlyofficeDisposables || [];
|
|
70
|
+
for (const off of disposables) { try { off(); } catch { /* 忽略 */ } }
|
|
71
|
+
disposables.length = 0;
|
|
72
|
+
|
|
73
|
+
for (const spec of PROVIDERS) {
|
|
74
|
+
disposables.push(we.registerEditorProvider({
|
|
75
|
+
id: spec.id,
|
|
76
|
+
displayName: spec.displayName,
|
|
77
|
+
sourceExt: "aiti-onlyoffice",
|
|
78
|
+
selector: { extensions: spec.extensions },
|
|
79
|
+
capabilities: ["preview", "edit"],
|
|
80
|
+
defaultCapability: "edit",
|
|
81
|
+
about: spec.about,
|
|
82
|
+
render: (host: any, c: any) => renderOnlyofficeView(host, c),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
(window as any).__aitiOnlyofficeDisposables = disposables;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function register(ctx: Record<string, any>): void {
|
|
90
|
+
// apiUrl(多实例 /dev 前缀安全)→ oo-view 的 config fetch 用
|
|
91
|
+
(window as any).__aitiOnlyofficeApiUrl = (p: string) => (typeof ctx.apiUrl === "function" ? ctx.apiUrl(p) : p);
|
|
92
|
+
// 状态探测(/status)推到「编辑器首次就绪」——即用户第一次打开文件/用编辑器时,
|
|
93
|
+
// 开页不再打这个请求。provider 晚到注册由编辑器仲裁层重新仲裁当前文件
|
|
94
|
+
// (registry.registerEditorProvider → fireActiveChange)。
|
|
95
|
+
whenAitiEditorReady(() => { void tryRegister(ctx); });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default register;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-onlyoffice · 视图(provider.render 挂载回调,纯 DOM 无 JSX)。
|
|
3
|
+
*
|
|
4
|
+
* 数据流(§2.4):GET /config(JWT 签名)→ 动态加载 DS api.js → DocsAPI.DocEditor iframe。
|
|
5
|
+
* 保存由 DS 内部经 callbackUrl 直写服务端(不碰 host.save / ContentProvider 管线)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const apiUrl = (p: string) => ((window as any).__aitiOnlyofficeApiUrl as (p: string) => string)?.(p) || p;
|
|
9
|
+
|
|
10
|
+
/** 加载占位 */
|
|
11
|
+
function loadingPane(text: string): HTMLElement {
|
|
12
|
+
const el = document.createElement("div");
|
|
13
|
+
el.setAttribute("data-oo-loading", "1");
|
|
14
|
+
el.style.cssText = "position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--text-muted,#888);font-size:13px;background:var(--bg,#fff);z-index:5;";
|
|
15
|
+
const spinner = document.createElement("div");
|
|
16
|
+
spinner.style.cssText = "width:22px;height:22px;border-radius:50%;border:2.5px solid var(--border,#e0e0e0);border-top-color:var(--primary,#6366f1);animation:aiti-oo-spin 0.8s linear infinite;";
|
|
17
|
+
const label = document.createElement("div");
|
|
18
|
+
label.textContent = text;
|
|
19
|
+
el.append(spinner, label);
|
|
20
|
+
if (!document.getElementById("aiti-oo-spin-style")) {
|
|
21
|
+
const style = document.createElement("style");
|
|
22
|
+
style.id = "aiti-oo-spin-style";
|
|
23
|
+
style.textContent = "@keyframes aiti-oo-spin{to{transform:rotate(360deg)}}";
|
|
24
|
+
document.head.appendChild(style);
|
|
25
|
+
}
|
|
26
|
+
return el;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clearLoading(container: HTMLElement): void {
|
|
30
|
+
container.querySelectorAll("[data-oo-loading]").forEach((n) => n.remove());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 错误态(含「尝试其他编辑器」提示位——打开方式菜单在宿主工具条 ▦) */
|
|
34
|
+
function errorPane(title: string, detail: string): HTMLElement {
|
|
35
|
+
const el = document.createElement("div");
|
|
36
|
+
el.style.cssText = "flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--danger, #f87171);font-size:13px;padding:16px;text-align:center;";
|
|
37
|
+
const t = document.createElement("div"); t.textContent = title;
|
|
38
|
+
const d = document.createElement("div"); d.style.cssText = "color:var(--text-dim,#888);font-size:12px;word-break:break-all;max-width:80%;"; d.textContent = detail;
|
|
39
|
+
const hint = document.createElement("div"); hint.style.cssText = "color:var(--text-dim,#888);font-size:12px;";
|
|
40
|
+
hint.textContent = "可用工具条「▦ 打开方式」切换其他编辑器";
|
|
41
|
+
el.append(t, d, hint);
|
|
42
|
+
return el;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* DS api.js(<dsPublicUrl>/web-apps/apps/api/documents/api.js → 全局 DocsAPI)。
|
|
47
|
+
* 必须走 <script src> 真实全局执行:api.js 顶层声明依赖全局作用域(loadVendorGlobal
|
|
48
|
+
* 的 new Function 包装会把顶层声明困在函数作用域内,window.DocsAPI 永不挂载)。
|
|
49
|
+
* api.js 非 UMD(无 define 检测),不受页面 Monaco AMD loader 影响。
|
|
50
|
+
*/
|
|
51
|
+
function loadDocsApi(dsPublicUrl: string): Promise<any> {
|
|
52
|
+
const w = window as any;
|
|
53
|
+
if (w.DocsAPI) return Promise.resolve(w.DocsAPI);
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const el = document.createElement("script");
|
|
56
|
+
el.src = dsPublicUrl.replace(/\/$/, "") + "/web-apps/apps/api/documents/api.js";
|
|
57
|
+
el.async = true;
|
|
58
|
+
el.onload = () => {
|
|
59
|
+
if (w.DocsAPI) resolve(w.DocsAPI);
|
|
60
|
+
else reject(new Error("api.js 已加载但 DocsAPI 未挂载(DS 版本异常?)"));
|
|
61
|
+
};
|
|
62
|
+
el.onerror = () => reject(new Error("api.js 加载失败: " + el.src));
|
|
63
|
+
document.head.appendChild(el);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* OnlyOffice DocEditor 视图。
|
|
69
|
+
* 返回 { el, dispose }(provider.render 契约)。
|
|
70
|
+
*/
|
|
71
|
+
export function renderOnlyofficeView(host: any, ctx: { filePath: string; cwd?: string }): { el: HTMLElement; dispose(): void } {
|
|
72
|
+
const el = document.createElement("div");
|
|
73
|
+
el.style.cssText = "display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--bg,#fff);position:relative;";
|
|
74
|
+
const viewport = document.createElement("div");
|
|
75
|
+
viewport.style.cssText = "flex:1;min-height:0;position:relative;overflow:hidden;";
|
|
76
|
+
// DS DocEditor 需要一个占位 div(按 id 替换内容为 iframe)
|
|
77
|
+
const placeholder = document.createElement("div");
|
|
78
|
+
placeholder.id = "aiti-oo-editor-" + Math.random().toString(36).slice(2, 8);
|
|
79
|
+
placeholder.style.cssText = "position:absolute;inset:0;";
|
|
80
|
+
viewport.appendChild(placeholder);
|
|
81
|
+
el.appendChild(viewport);
|
|
82
|
+
viewport.appendChild(loadingPane("连接 OnlyOffice DocumentServer…"));
|
|
83
|
+
|
|
84
|
+
let dead = false;
|
|
85
|
+
let editor: any = null;
|
|
86
|
+
|
|
87
|
+
(async () => {
|
|
88
|
+
const q = new URLSearchParams({ filePath: ctx.filePath, ...(ctx.cwd ? { cwd: ctx.cwd } : {}) });
|
|
89
|
+
const d = await fetch(apiUrl("/api/extensions/aiti-onlyoffice/config?" + q.toString()), { cache: "no-store" }).then((r) => r.json());
|
|
90
|
+
if (dead) return;
|
|
91
|
+
if (!d?.ok) throw new Error(d?.error || "config 获取失败");
|
|
92
|
+
const DocsAPI = await loadDocsApi((window as any).__aitiOnlyofficeDsPublicUrl || new URL(apiUrl("/"), location.href).origin || "");
|
|
93
|
+
if (dead) return;
|
|
94
|
+
clearLoading(viewport);
|
|
95
|
+
editor = new DocsAPI.DocEditor(placeholder.id, {
|
|
96
|
+
...d.config,
|
|
97
|
+
width: "100%",
|
|
98
|
+
height: "100%",
|
|
99
|
+
events: {
|
|
100
|
+
onDocumentReady: () => { /* DS 内部管理保存与脏态 */ },
|
|
101
|
+
onError: (e: any) => console.warn("[aiti-onlyoffice] DS 事件:", e),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
})().catch((e) => {
|
|
105
|
+
if (dead) return;
|
|
106
|
+
clearLoading(viewport);
|
|
107
|
+
viewport.querySelectorAll("iframe").forEach((f) => f.remove());
|
|
108
|
+
viewport.appendChild(errorPane("OnlyOffice 打开失败", String((e && e.message) || e)));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
el,
|
|
113
|
+
dispose() {
|
|
114
|
+
dead = true;
|
|
115
|
+
try { editor?.destroyEditor?.(); } catch { /* 忽略 */ }
|
|
116
|
+
editor = null;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|