@joplo-app/server 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/LICENSE +55 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.js +218 -0
- package/dist/contract.d.ts +76 -0
- package/dist/contract.js +1 -0
- package/dist/dev.d.ts +100 -0
- package/dist/dev.js +667 -0
- package/dist/export.d.ts +82 -0
- package/dist/export.js +245 -0
- package/dist/home.d.ts +49 -0
- package/dist/home.js +168 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +3 -0
- package/dist/page-report.js +82 -0
- package/dist/project.d.ts +14 -0
- package/dist/project.js +79 -0
- package/dist/render.d.ts +9 -0
- package/dist/render.js +150 -0
- package/dist/selection.d.ts +35 -0
- package/dist/selection.js +123 -0
- package/dist/vite-shared.d.ts +29 -0
- package/dist/vite-shared.js +100 -0
- package/dist/web-assets.d.ts +35 -0
- package/dist/web-assets.js +127 -0
- package/package.json +53 -3
- package/README.md +0 -3
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
import { type Project } from '@joplo-app/core';
|
|
3
|
+
import { type PlaywrightModule } from './render.js';
|
|
4
|
+
export type Device = 'desktop' | 'mobile';
|
|
5
|
+
/**
|
|
6
|
+
* 导出画面的尺寸:电脑 = 舞台 1280×800;手机 = 外框(renderer 的 phoneFrameSize(390, 844) = 446×934)
|
|
7
|
+
* 四周再留 16px(RenderApp 的 frame 模式)。舞台正下方是字幕区(SUBTITLE_HEIGHT),一起导出。
|
|
8
|
+
*/
|
|
9
|
+
export declare const EXPORT_SIZE: Record<Device, {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}>;
|
|
13
|
+
export type VideoJob = {
|
|
14
|
+
id: string;
|
|
15
|
+
projectId: string;
|
|
16
|
+
scenarioId: string;
|
|
17
|
+
status: 'running' | 'done' | 'error';
|
|
18
|
+
/** starting 启动浏览器 → recording 录制中 → saving 写文件。 */
|
|
19
|
+
phase: 'starting' | 'recording' | 'saving' | 'done' | 'error';
|
|
20
|
+
step: number;
|
|
21
|
+
total: number;
|
|
22
|
+
/** 0–100。 */
|
|
23
|
+
percent: number;
|
|
24
|
+
/** 完成后:相对项目目录的路径(exports/…webm)。 */
|
|
25
|
+
file: string | null;
|
|
26
|
+
error: string | null;
|
|
27
|
+
};
|
|
28
|
+
/** 录制实现:报告进度,返回写好的文件绝对路径。测试可注入。 */
|
|
29
|
+
export type VideoRecorder = (input: {
|
|
30
|
+
url: string;
|
|
31
|
+
size: {
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
};
|
|
35
|
+
out: string;
|
|
36
|
+
total: number;
|
|
37
|
+
progress: (step: number) => void;
|
|
38
|
+
signal: AbortSignal;
|
|
39
|
+
}) => Promise<void>;
|
|
40
|
+
export type ExportDeps = {
|
|
41
|
+
loadPlaywright: () => Promise<PlaywrightModule>;
|
|
42
|
+
recordVideo: VideoRecorder;
|
|
43
|
+
};
|
|
44
|
+
type Ctx = {
|
|
45
|
+
projectId: string;
|
|
46
|
+
dir: string;
|
|
47
|
+
project: Project | null;
|
|
48
|
+
/** Origin + 令牌(+ needJson 时 Content-Type)检查;false 表示已拒绝并写好响应。 */
|
|
49
|
+
guardWrite: (needJson?: boolean) => boolean;
|
|
50
|
+
jsonBody: () => Promise<{
|
|
51
|
+
ok: true;
|
|
52
|
+
body: unknown;
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
}>;
|
|
56
|
+
fail: (e: unknown) => void;
|
|
57
|
+
};
|
|
58
|
+
/** 缺省的录制:Playwright recordVideo + 渲染路由的录制模式(window.__JOPLO_RECORD__)。 */
|
|
59
|
+
export declare function defaultRecorder(load: () => Promise<PlaywrightModule>): VideoRecorder;
|
|
60
|
+
export declare class ExportHub {
|
|
61
|
+
private readonly opts;
|
|
62
|
+
private jobs;
|
|
63
|
+
private listeners;
|
|
64
|
+
private aborts;
|
|
65
|
+
private running;
|
|
66
|
+
private deps;
|
|
67
|
+
constructor(opts: {
|
|
68
|
+
baseUrl: () => string;
|
|
69
|
+
log: (s: string) => void;
|
|
70
|
+
deps?: Partial<ExportDeps>;
|
|
71
|
+
onEvent?: (j: VideoJob) => void;
|
|
72
|
+
});
|
|
73
|
+
private renderUrl;
|
|
74
|
+
job(projectId: string): VideoJob | null;
|
|
75
|
+
private update;
|
|
76
|
+
handle(sub: string, req: http.IncomingMessage, res: http.ServerResponse, ctx: Ctx): Promise<void>;
|
|
77
|
+
private image;
|
|
78
|
+
private startVideo;
|
|
79
|
+
private sse;
|
|
80
|
+
close(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
export {};
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 看板「…」菜单的导出(D39):
|
|
3
|
+
*
|
|
4
|
+
* - 图片:`POST /__joplo/p/<id>/export/image` `{ scenarioId, position, theme }` → PNG(当前步骤;尺寸跟随页面自己的 viewport,
|
|
5
|
+
* 手机页面带外框;D44 起不再选择设备;镜头用这一步的导演镜头)。Playwright 打开本看板的渲染路由(与 renderStep 同一条渲染路径),截取 `[data-jexport]`。
|
|
6
|
+
* - 视频(只在本机):`POST …/export/video` `{ scenarioId, theme }` → 202 `{ jobId }`(尺寸跟随场景起始页面的 viewport);
|
|
7
|
+
* Playwright `recordVideo` 录下渲染路由的自动播放(含鼠标与字幕),保存为 `<项目>/exports/<场景>-<时间>.webm`。
|
|
8
|
+
* 进度:`GET …/export/video/events`(SSE,先发当前状态,之后每次变化一条,结束后关闭);`GET …/export/video` 取当前状态。
|
|
9
|
+
* 同一项目同时只能有一个视频任务(再提交返回 409)。没有 Playwright / Chromium 时任务失败,错误里带安装命令。
|
|
10
|
+
*
|
|
11
|
+
* 字段流向:job(写入点:这里的 runJob)→ jobs Map(存储)→ SSE / GET(读取点)→ 看板的导出视频弹窗(消费者)。
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { JoploError, SUBTITLE_HEIGHT, THEME_NAME, displaySceneId } from '@joplo-app/core';
|
|
17
|
+
import { launch, loadPlaywright } from "./render.js";
|
|
18
|
+
/**
|
|
19
|
+
* 导出画面的尺寸:电脑 = 舞台 1280×800;手机 = 外框(renderer 的 phoneFrameSize(390, 844) = 446×934)
|
|
20
|
+
* 四周再留 16px(RenderApp 的 frame 模式)。舞台正下方是字幕区(SUBTITLE_HEIGHT),一起导出。
|
|
21
|
+
*/
|
|
22
|
+
export const EXPORT_SIZE = {
|
|
23
|
+
desktop: { width: 1280, height: 800 + SUBTITLE_HEIGHT },
|
|
24
|
+
mobile: { width: 446 + 32, height: 934 + 32 + SUBTITLE_HEIGHT },
|
|
25
|
+
};
|
|
26
|
+
const send = (res, status, body) => {
|
|
27
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
28
|
+
res.end(JSON.stringify(body));
|
|
29
|
+
};
|
|
30
|
+
function parseRequest(project, body, needPosition) {
|
|
31
|
+
if (!project)
|
|
32
|
+
throw new JoploError('INVALID_SCHEMA', '项目还没有成功编译过,无法导出');
|
|
33
|
+
const b = (body ?? {});
|
|
34
|
+
if (typeof b.scenarioId !== 'string' || !project.scenarios[b.scenarioId])
|
|
35
|
+
throw new JoploError('USAGE', `场景不存在:${String(b.scenarioId)}`);
|
|
36
|
+
const sc = project.scenarios[b.scenarioId];
|
|
37
|
+
const position = needPosition ? Number(b.position ?? 0) : 0;
|
|
38
|
+
if (!Number.isInteger(position) || position < 0 || position > sc.steps.length)
|
|
39
|
+
throw new JoploError('USAGE', `位置应为 0–${sc.steps.length} 的整数`);
|
|
40
|
+
// 设备跟随页面自己的 viewport(D44)
|
|
41
|
+
const sceneId = displaySceneId(project, sc.id, position);
|
|
42
|
+
const device = project.scenes[sceneId]?.viewport === 'mobile' ? 'mobile' : 'desktop';
|
|
43
|
+
// 主题(D51):请求里的主题名,没有或不合法时用项目配置的主题
|
|
44
|
+
const theme = typeof b.theme === 'string' && THEME_NAME.test(b.theme) ? b.theme : project.theme;
|
|
45
|
+
return { sc, position, device, theme };
|
|
46
|
+
}
|
|
47
|
+
const fileSafe = (s) => s.replace(/[^\w.-]+/g, '_').slice(0, 80) || 'scenario';
|
|
48
|
+
const stamp = (d = new Date()) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
|
|
49
|
+
async function waitReady(page) {
|
|
50
|
+
await page.waitForFunction(() => window.__JOPLO_READY__ === true, null, { timeout: 60000 });
|
|
51
|
+
const err = await page.evaluate(() => window.__JOPLO_ERROR__ ?? null);
|
|
52
|
+
if (err)
|
|
53
|
+
throw new JoploError('RENDER_FAILED', `渲染路由报告错误:${err}`);
|
|
54
|
+
}
|
|
55
|
+
/** 缺省的录制:Playwright recordVideo + 渲染路由的录制模式(window.__JOPLO_RECORD__)。 */
|
|
56
|
+
export function defaultRecorder(load) {
|
|
57
|
+
return async ({ url, size, out, progress, signal }) => {
|
|
58
|
+
const pw = await load();
|
|
59
|
+
const browser = await launch(pw);
|
|
60
|
+
const tmp = fs.mkdtempSync(path.join(path.dirname(out), '.rec-'));
|
|
61
|
+
try {
|
|
62
|
+
const ctx = await browser.newContext({ viewport: size, deviceScaleFactor: 1, recordVideo: { dir: tmp, size } });
|
|
63
|
+
const page = await ctx.newPage();
|
|
64
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
65
|
+
await waitReady(page);
|
|
66
|
+
await page.waitForTimeout(400);
|
|
67
|
+
await page.evaluate(() => window.__JOPLO_RECORD__.play());
|
|
68
|
+
const t0 = Date.now();
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (signal.aborted)
|
|
71
|
+
throw new JoploError('EXPORT_FAILED', '导出已取消');
|
|
72
|
+
const p = await page.evaluate(() => window.__JOPLO_RECORD__.progress);
|
|
73
|
+
if (p.error)
|
|
74
|
+
throw new JoploError('EXPORT_FAILED', `播放出错:${p.error}`);
|
|
75
|
+
progress(p.step);
|
|
76
|
+
if (p.done)
|
|
77
|
+
break;
|
|
78
|
+
if (Date.now() - t0 > 5 * 60_000)
|
|
79
|
+
throw new JoploError('EXPORT_FAILED', '录制超过 5 分钟,已停止');
|
|
80
|
+
await page.waitForTimeout(150);
|
|
81
|
+
}
|
|
82
|
+
await page.waitForTimeout(900); // 停留在结果画面
|
|
83
|
+
const video = page.video();
|
|
84
|
+
await ctx.close();
|
|
85
|
+
if (!video)
|
|
86
|
+
throw new JoploError('EXPORT_FAILED', '浏览器没有生成视频');
|
|
87
|
+
await video.saveAs(out);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await browser.close().catch(() => undefined);
|
|
91
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export class ExportHub {
|
|
96
|
+
opts;
|
|
97
|
+
jobs = new Map();
|
|
98
|
+
listeners = new Map();
|
|
99
|
+
aborts = new Map();
|
|
100
|
+
running = new Set();
|
|
101
|
+
deps;
|
|
102
|
+
constructor(opts) {
|
|
103
|
+
this.opts = opts;
|
|
104
|
+
const load = opts.deps?.loadPlaywright ?? (() => loadPlaywright());
|
|
105
|
+
this.deps = { loadPlaywright: load, recordVideo: opts.deps?.recordVideo ?? defaultRecorder(load) };
|
|
106
|
+
}
|
|
107
|
+
renderUrl(projectId, q) {
|
|
108
|
+
return `${this.opts.baseUrl()}?${new URLSearchParams({ render: '1', project: projectId, ...q })}`;
|
|
109
|
+
}
|
|
110
|
+
job(projectId) {
|
|
111
|
+
return this.jobs.get(projectId) ?? null;
|
|
112
|
+
}
|
|
113
|
+
update(job, patch) {
|
|
114
|
+
Object.assign(job, patch);
|
|
115
|
+
const snap = { ...job };
|
|
116
|
+
for (const l of this.listeners.get(job.projectId) ?? [])
|
|
117
|
+
l(snap);
|
|
118
|
+
this.opts.onEvent?.(snap);
|
|
119
|
+
}
|
|
120
|
+
async handle(sub, req, res, ctx) {
|
|
121
|
+
const method = req.method ?? 'GET';
|
|
122
|
+
try {
|
|
123
|
+
if (sub === 'image' && method === 'POST') {
|
|
124
|
+
if (!ctx.guardWrite())
|
|
125
|
+
return;
|
|
126
|
+
const b = await ctx.jsonBody();
|
|
127
|
+
if (!b.ok)
|
|
128
|
+
return;
|
|
129
|
+
const { sc, position, device, theme } = parseRequest(ctx.project, b.body, true);
|
|
130
|
+
const png = await this.image(ctx.projectId, sc.id, position, device, theme);
|
|
131
|
+
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store', 'content-disposition': `attachment; filename="${fileSafe(sc.id)}-${position}.png"` });
|
|
132
|
+
res.end(png);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (sub === 'video' && method === 'POST') {
|
|
136
|
+
if (!ctx.guardWrite())
|
|
137
|
+
return;
|
|
138
|
+
const b = await ctx.jsonBody();
|
|
139
|
+
if (!b.ok)
|
|
140
|
+
return;
|
|
141
|
+
const { sc, device, theme } = parseRequest(ctx.project, b.body, false);
|
|
142
|
+
const cur = this.jobs.get(ctx.projectId);
|
|
143
|
+
if (cur?.status === 'running')
|
|
144
|
+
return send(res, 409, { error: { code: 'UNSUPPORTED_ACTION', message: '这个项目已有一个视频正在导出,请等它完成' }, job: cur });
|
|
145
|
+
const job = this.startVideo(ctx.projectId, ctx.dir, sc.id, sc.steps.length, device, theme);
|
|
146
|
+
return send(res, 202, { jobId: job.id, job });
|
|
147
|
+
}
|
|
148
|
+
if (sub === 'video' && method === 'GET')
|
|
149
|
+
return send(res, 200, { job: this.job(ctx.projectId) });
|
|
150
|
+
if (sub === 'video/events' && method === 'GET') {
|
|
151
|
+
if (!ctx.guardWrite(false))
|
|
152
|
+
return;
|
|
153
|
+
return this.sse(ctx.projectId, res);
|
|
154
|
+
}
|
|
155
|
+
return send(res, 404, { error: { code: 'NOT_FOUND', message: `没有这个接口:${method} export/${sub}` } });
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (!res.headersSent)
|
|
159
|
+
ctx.fail(e);
|
|
160
|
+
else
|
|
161
|
+
res.end();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async image(projectId, scenarioId, position, device, theme) {
|
|
165
|
+
const pw = await this.deps.loadPlaywright();
|
|
166
|
+
const browser = await launch(pw);
|
|
167
|
+
try {
|
|
168
|
+
const page = await browser.newPage({ viewport: EXPORT_SIZE[device], deviceScaleFactor: 2, reducedMotion: 'reduce' });
|
|
169
|
+
await page.goto(this.renderUrl(projectId, { scenario: scenarioId, position: String(position), theme, frame: '1' }), { waitUntil: 'domcontentloaded' });
|
|
170
|
+
await waitReady(page);
|
|
171
|
+
return await page.locator('[data-jexport]').screenshot({ type: 'png', animations: 'disabled' });
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await browser.close().catch(() => undefined);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
startVideo(projectId, dir, scenarioId, total, device, theme) {
|
|
178
|
+
const job = { id: crypto.randomBytes(6).toString('hex'), projectId, scenarioId, status: 'running', phase: 'starting', step: 0, total, percent: 0, file: null, error: null };
|
|
179
|
+
this.jobs.set(projectId, job);
|
|
180
|
+
const ac = new AbortController();
|
|
181
|
+
this.aborts.set(projectId, ac);
|
|
182
|
+
const rel = `exports/${fileSafe(scenarioId)}-${stamp()}.webm`;
|
|
183
|
+
const out = path.join(dir, rel);
|
|
184
|
+
const p = (async () => {
|
|
185
|
+
try {
|
|
186
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
187
|
+
this.update(job, { phase: 'starting', percent: 2 });
|
|
188
|
+
await this.deps.recordVideo({
|
|
189
|
+
url: this.renderUrl(projectId, { scenario: scenarioId, position: '0', theme, frame: '1', record: '1' }),
|
|
190
|
+
size: EXPORT_SIZE[device],
|
|
191
|
+
out,
|
|
192
|
+
total,
|
|
193
|
+
signal: ac.signal,
|
|
194
|
+
progress: (step) => {
|
|
195
|
+
const s = Math.max(0, Math.min(step, total));
|
|
196
|
+
const percent = Math.round(5 + (total ? s / total : 1) * 85);
|
|
197
|
+
if (job.phase !== 'recording' || s !== job.step)
|
|
198
|
+
this.update(job, { phase: 'recording', step: s, percent });
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
this.update(job, { phase: 'saving', percent: 95 });
|
|
202
|
+
if (!fs.existsSync(out))
|
|
203
|
+
throw new JoploError('EXPORT_FAILED', '录制完成,但没有生成视频文件');
|
|
204
|
+
this.update(job, { status: 'done', phase: 'done', percent: 100, step: total, file: rel });
|
|
205
|
+
this.opts.log(`[joplo] 已导出视频:${out}`);
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
this.update(job, { status: 'error', phase: 'error', error: e instanceof Error ? e.message : String(e) });
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
this.aborts.delete(projectId);
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
this.running.add(p);
|
|
215
|
+
void p.finally(() => this.running.delete(p));
|
|
216
|
+
return job;
|
|
217
|
+
}
|
|
218
|
+
sse(projectId, res) {
|
|
219
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive', 'x-content-type-options': 'nosniff' });
|
|
220
|
+
const write = (j) => res.write(`data: ${JSON.stringify(j)}\n\n`);
|
|
221
|
+
const cur = this.job(projectId);
|
|
222
|
+
write(cur);
|
|
223
|
+
if (!cur || cur.status !== 'running') {
|
|
224
|
+
res.end();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const set = this.listeners.get(projectId) ?? new Set();
|
|
228
|
+
this.listeners.set(projectId, set);
|
|
229
|
+
const l = (j) => {
|
|
230
|
+
write(j);
|
|
231
|
+
if (j.status !== 'running') {
|
|
232
|
+
set.delete(l);
|
|
233
|
+
res.end();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
set.add(l);
|
|
237
|
+
res.on('close', () => set.delete(l));
|
|
238
|
+
}
|
|
239
|
+
async close() {
|
|
240
|
+
for (const ac of this.aborts.values())
|
|
241
|
+
ac.abort();
|
|
242
|
+
await Promise.allSettled([...this.running]);
|
|
243
|
+
this.listeners.clear();
|
|
244
|
+
}
|
|
245
|
+
}
|
package/dist/home.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type ProjectRecord = {
|
|
2
|
+
dir: string;
|
|
3
|
+
name: string;
|
|
4
|
+
lastOpened: string;
|
|
5
|
+
};
|
|
6
|
+
export type ServerInfo = {
|
|
7
|
+
port: number;
|
|
8
|
+
pid: number;
|
|
9
|
+
token: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function joploHome(env?: Record<string, string | undefined>): string;
|
|
12
|
+
/** 项目在 URL 里的标识:目录名(小写、只留安全字符)+ 目录绝对路径的短哈希,稳定且不暴露完整路径。 */
|
|
13
|
+
export declare function projectIdOf(dir: string): string;
|
|
14
|
+
export declare class Registry {
|
|
15
|
+
readonly home: string;
|
|
16
|
+
readonly file: string;
|
|
17
|
+
constructor(home: string);
|
|
18
|
+
/** 读取记录(文件不存在或损坏时为空列表;无效条目跳过),按最近打开排序。 */
|
|
19
|
+
list(): ProjectRecord[];
|
|
20
|
+
private save;
|
|
21
|
+
/** 登记或更新一个项目(打开时间刷新为现在)。 */
|
|
22
|
+
touch(dir: string, name?: string): ProjectRecord;
|
|
23
|
+
/** 只更新名称(编译成功后用 config 的 name),不改变打开时间;记录不存在时不新增。 */
|
|
24
|
+
rename(dir: string, name: string): void;
|
|
25
|
+
/** 从列表移除;返回是否存在。只改 projects.json。 */
|
|
26
|
+
remove(dir: string): boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare const serverInfoFile: (home: string) => string;
|
|
29
|
+
export declare function readServerInfo(home: string): ServerInfo | null;
|
|
30
|
+
export declare function writeServerInfo(home: string, info: ServerInfo): void;
|
|
31
|
+
/** 只删除自己写的 server.json(pid 一致),不误删后来者的。 */
|
|
32
|
+
export declare function clearServerInfo(home: string, pid: number): void;
|
|
33
|
+
export declare function pidAlive(pid: number): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* `{ version: 1, theme?: string | null, projects?: { [项目 id]: { theme: string | null } } }`
|
|
36
|
+
* theme:所有项目的主题选择;projects[id].theme:按项目覆盖,null 表示“恢复为项目默认”(使用项目 config 里的主题)。
|
|
37
|
+
* 写入点:看板设置页(PUT /__joplo/settings);读取点:看板演示页与设置页(GET /__joplo/settings)。原子写入。
|
|
38
|
+
*/
|
|
39
|
+
export type BoardSettings = {
|
|
40
|
+
version: 1;
|
|
41
|
+
theme?: string | null;
|
|
42
|
+
projects?: Record<string, {
|
|
43
|
+
theme: string | null;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
/** 校验并规范化设置(未知字段丢弃,主题名不合法的条目丢弃)。 */
|
|
47
|
+
export declare function normalizeSettings(v: unknown): BoardSettings;
|
|
48
|
+
export declare function readSettings(home: string): BoardSettings;
|
|
49
|
+
export declare function writeSettings(home: string, s: unknown): BoardSettings;
|
package/dist/home.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机看板的持久数据(D39):一个本机只运行一个看板进程,它记住打开过的项目。
|
|
3
|
+
*
|
|
4
|
+
* - `<JOPLO_HOME>/projects.json`:`{ version: 1, projects: [{ dir, name, lastOpened }] }`,按 lastOpened 倒序显示。
|
|
5
|
+
* 写入点:hub 启动时登记首个项目、注册接口(再次 `joplo dev`)、编译成功后更新名称、移除接口。
|
|
6
|
+
* 读取点:hub 的项目列表接口 → 看板首页左栏。原子写入(临时文件 + rename)。
|
|
7
|
+
* - `<JOPLO_HOME>/server.json`:`{ port, pid, token }`,权限 0600。写入点:hub 开始监听后;
|
|
8
|
+
* 读取点:`joplo dev` 启动时判断是否已有存活的看板;hub 关闭时删除(只删自己写的那份)。
|
|
9
|
+
*
|
|
10
|
+
* JOPLO_HOME 缺省为 ~/.joplo。测试一律把 JOPLO_HOME 指向临时目录。
|
|
11
|
+
* 删除记录只改 projects.json,从不触碰项目目录。
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
export function joploHome(env = process.env) {
|
|
18
|
+
const v = env.JOPLO_HOME;
|
|
19
|
+
return v && v.trim() ? path.resolve(v) : path.join(os.homedir(), '.joplo');
|
|
20
|
+
}
|
|
21
|
+
/** 项目在 URL 里的标识:目录名(小写、只留安全字符)+ 目录绝对路径的短哈希,稳定且不暴露完整路径。 */
|
|
22
|
+
export function projectIdOf(dir) {
|
|
23
|
+
const base = path.basename(dir).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'project';
|
|
24
|
+
const hash = crypto.createHash('sha256').update(dir).digest('hex').slice(0, 6);
|
|
25
|
+
return `${base}-${hash}`;
|
|
26
|
+
}
|
|
27
|
+
function atomicWrite(file, text, mode) {
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
29
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
30
|
+
fs.writeFileSync(tmp, text, mode === undefined ? undefined : { mode });
|
|
31
|
+
if (mode !== undefined)
|
|
32
|
+
fs.chmodSync(tmp, mode);
|
|
33
|
+
fs.renameSync(tmp, file);
|
|
34
|
+
}
|
|
35
|
+
const isRecord = (v) => {
|
|
36
|
+
const r = v;
|
|
37
|
+
return !!r && typeof r === 'object' && typeof r.dir === 'string' && path.isAbsolute(r.dir) && typeof r.name === 'string' && typeof r.lastOpened === 'string';
|
|
38
|
+
};
|
|
39
|
+
export class Registry {
|
|
40
|
+
home;
|
|
41
|
+
file;
|
|
42
|
+
constructor(home) {
|
|
43
|
+
this.home = home;
|
|
44
|
+
this.file = path.join(home, 'projects.json');
|
|
45
|
+
}
|
|
46
|
+
/** 读取记录(文件不存在或损坏时为空列表;无效条目跳过),按最近打开排序。 */
|
|
47
|
+
list() {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const items = raw?.projects;
|
|
56
|
+
if (!Array.isArray(items))
|
|
57
|
+
return [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const it of items) {
|
|
61
|
+
if (!isRecord(it) || seen.has(it.dir))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(it.dir);
|
|
64
|
+
out.push({ dir: it.dir, name: it.name, lastOpened: it.lastOpened });
|
|
65
|
+
}
|
|
66
|
+
return out.sort((a, b) => b.lastOpened.localeCompare(a.lastOpened));
|
|
67
|
+
}
|
|
68
|
+
save(list) {
|
|
69
|
+
atomicWrite(this.file, JSON.stringify({ version: 1, projects: list }, null, 2) + '\n');
|
|
70
|
+
}
|
|
71
|
+
/** 登记或更新一个项目(打开时间刷新为现在)。 */
|
|
72
|
+
touch(dir, name) {
|
|
73
|
+
const list = this.list();
|
|
74
|
+
const prev = list.find((p) => p.dir === dir);
|
|
75
|
+
const rec = { dir, name: name ?? prev?.name ?? path.basename(dir), lastOpened: new Date().toISOString() };
|
|
76
|
+
this.save([rec, ...list.filter((p) => p.dir !== dir)]);
|
|
77
|
+
return rec;
|
|
78
|
+
}
|
|
79
|
+
/** 只更新名称(编译成功后用 config 的 name),不改变打开时间;记录不存在时不新增。 */
|
|
80
|
+
rename(dir, name) {
|
|
81
|
+
const list = this.list();
|
|
82
|
+
const rec = list.find((p) => p.dir === dir);
|
|
83
|
+
if (!rec || rec.name === name)
|
|
84
|
+
return;
|
|
85
|
+
rec.name = name;
|
|
86
|
+
this.save(list);
|
|
87
|
+
}
|
|
88
|
+
/** 从列表移除;返回是否存在。只改 projects.json。 */
|
|
89
|
+
remove(dir) {
|
|
90
|
+
const list = this.list();
|
|
91
|
+
const next = list.filter((p) => p.dir !== dir);
|
|
92
|
+
if (next.length === list.length)
|
|
93
|
+
return false;
|
|
94
|
+
this.save(next);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export const serverInfoFile = (home) => path.join(home, 'server.json');
|
|
99
|
+
export function readServerInfo(home) {
|
|
100
|
+
try {
|
|
101
|
+
const v = JSON.parse(fs.readFileSync(serverInfoFile(home), 'utf8'));
|
|
102
|
+
if (Number.isInteger(v.port) && v.port > 0 && Number.isInteger(v.pid) && v.pid > 0 && typeof v.token === 'string' && v.token)
|
|
103
|
+
return v;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// 不存在或损坏:视为没有运行中的看板
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
export function writeServerInfo(home, info) {
|
|
111
|
+
atomicWrite(serverInfoFile(home), JSON.stringify(info) + '\n', 0o600);
|
|
112
|
+
}
|
|
113
|
+
/** 只删除自己写的 server.json(pid 一致),不误删后来者的。 */
|
|
114
|
+
export function clearServerInfo(home, pid) {
|
|
115
|
+
const cur = readServerInfo(home);
|
|
116
|
+
if (cur && cur.pid !== pid)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
fs.rmSync(serverInfoFile(home), { force: true });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// 忽略
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function pidAlive(pid) {
|
|
126
|
+
try {
|
|
127
|
+
process.kill(pid, 0);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return e.code === 'EPERM';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const NAME = /^[A-Za-z][\w-]*$/;
|
|
135
|
+
/** 校验并规范化设置(未知字段丢弃,主题名不合法的条目丢弃)。 */
|
|
136
|
+
export function normalizeSettings(v) {
|
|
137
|
+
const o = (v && typeof v === 'object' ? v : {});
|
|
138
|
+
const out = { version: 1 };
|
|
139
|
+
if (typeof o.theme === 'string' && NAME.test(o.theme))
|
|
140
|
+
out.theme = o.theme;
|
|
141
|
+
else if (o.theme === null)
|
|
142
|
+
out.theme = null;
|
|
143
|
+
const projects = o.projects && typeof o.projects === 'object' ? o.projects : {};
|
|
144
|
+
const p = {};
|
|
145
|
+
for (const [id, x] of Object.entries(projects)) {
|
|
146
|
+
if (!/^[\w-]{1,64}$/.test(id) || !x || typeof x !== 'object')
|
|
147
|
+
continue;
|
|
148
|
+
const t = x.theme;
|
|
149
|
+
if (t === null || (typeof t === 'string' && NAME.test(t)))
|
|
150
|
+
p[id] = { theme: t };
|
|
151
|
+
}
|
|
152
|
+
if (Object.keys(p).length)
|
|
153
|
+
out.projects = p;
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
export function readSettings(home) {
|
|
157
|
+
try {
|
|
158
|
+
return normalizeSettings(JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8')));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return { version: 1 };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export function writeSettings(home, s) {
|
|
165
|
+
const n = normalizeSettings(s);
|
|
166
|
+
atomicWrite(path.join(home, 'settings.json'), JSON.stringify(n, null, 2) + '\n');
|
|
167
|
+
return n;
|
|
168
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @joplo-app/server:CLI 只依赖这里导出的三个函数(接口见 contract.ts)。
|
|
3
|
+
*
|
|
4
|
+
* - startDev:单进程 Vite 开发服务,提供看板、编译与热更新;选中写入 .joplo/selection.json。
|
|
5
|
+
* - buildSite:静态发布站(只含选定方案,hash 路由,file:// 可直接打开)。
|
|
6
|
+
* - renderStep:某个场景某一步之后的截图、元素索引(带 at)与渲染告警。
|
|
7
|
+
*/
|
|
8
|
+
export type { BuildOptions, BuildResult, DevHandle, DevOptions, RenderOptions, RenderResult, RenderedElement } from './contract.js';
|
|
9
|
+
export { startDev } from './dev.js';
|
|
10
|
+
export { buildSite } from './build.js';
|
|
11
|
+
export { renderStep } from './render.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* 在渲染页面内执行(Playwright page.evaluate),生成元素索引与渲染告警(PRD 8.8 / AC-22)。
|
|
2
|
+
* 纯 JS,运行时以文本注入,避免转译器注入辅助函数。参数:{ nextTargetId, theme }。 */
|
|
3
|
+
(args) => {
|
|
4
|
+
const stage = document.querySelector('[data-joplo-stage]');
|
|
5
|
+
const sr = stage.getBoundingClientRect();
|
|
6
|
+
const warnings = [];
|
|
7
|
+
// 文本节点(#text)不进索引:它们属于父元素
|
|
8
|
+
const els = Array.from(stage.querySelectorAll('[data-jid]:not([data-jtype="#text"])'));
|
|
9
|
+
const name = (el) => {
|
|
10
|
+
const owner = el.closest('[data-jid]:not([data-jtype="#text"])');
|
|
11
|
+
return owner ? `「${owner.dataset.jlabel ?? ''}」(${owner.dataset.jid})` : el.closest('[data-jtoast]') ? 'Toast' : el.closest('[data-jbackdrop]') ? '弹窗遮罩' : el.tagName.toLowerCase();
|
|
12
|
+
};
|
|
13
|
+
const elements = els.map((el) => {
|
|
14
|
+
const r = el.getBoundingClientRect();
|
|
15
|
+
const box = { x: Math.round(r.left - sr.left), y: Math.round(r.top - sr.top), width: Math.round(r.width), height: Math.round(r.height) };
|
|
16
|
+
const style = getComputedStyle(el);
|
|
17
|
+
const intersects = r.right > sr.left && r.left < sr.right && r.bottom > sr.top && r.top < sr.bottom;
|
|
18
|
+
const visible = r.width > 0 && r.height > 0 && intersects && style.visibility !== 'hidden' && style.display !== 'none';
|
|
19
|
+
return { id: el.dataset.jid, type: el.dataset.jtype, label: el.dataset.jlabel ?? '', visible, disabled: el.hasAttribute('data-jdisabled'), at: el.dataset.jat ?? null, box };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const vw = window.innerWidth;
|
|
23
|
+
const vh = window.innerHeight;
|
|
24
|
+
const offViewport = [];
|
|
25
|
+
const dialogOpen = !!stage.querySelector('[data-jbackdrop]');
|
|
26
|
+
const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 2 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 2;
|
|
27
|
+
|
|
28
|
+
// 1. 可见的可交互元素:中心点必须命中它自身
|
|
29
|
+
els.forEach((el, i) => {
|
|
30
|
+
const e = elements[i];
|
|
31
|
+
if (!el.hasAttribute('data-jinteractive') || !e.visible) return;
|
|
32
|
+
const r = el.getBoundingClientRect();
|
|
33
|
+
const cx = r.left + r.width / 2;
|
|
34
|
+
const cy = r.top + r.height / 2;
|
|
35
|
+
if (cx < sr.left || cx > sr.right || cy < sr.top || cy > sr.bottom) {
|
|
36
|
+
warnings.push(`超出画框:「${e.label}」(${e.id}) 的中心点不在画框内`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// 视口小于舞台时,视口外的点 elementFromPoint 返回 null——那不是遮挡,单独汇总
|
|
40
|
+
if (cx < 0 || cy < 0 || cx >= vw || cy >= vh) {
|
|
41
|
+
offViewport.push(e.id);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
45
|
+
if (hit && (hit === el || el.contains(hit))) return;
|
|
46
|
+
// 弹窗打开时,背景被遮罩或弹窗面板覆盖是预期的层次,不算遮挡(窄屏下面板常盖住背景按钮)
|
|
47
|
+
if ((hit?.closest('[data-jbackdrop]') || (dialogOpen && hit?.closest('[role="dialog"]'))) && !el.closest('[role="dialog"]')) return;
|
|
48
|
+
warnings.push(`被遮挡:「${e.label}」(${e.id}) 的中心点被 ${hit ? name(hit) : '画框外内容'} 覆盖`);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (offViewport.length)
|
|
52
|
+
warnings.push(`不在视口内:视口 ${vw}×${vh} 小于舞台,${offViewport.length} 个可交互元素的中心点在视口外,未做遮挡检查(${offViewport.join(', ')})`);
|
|
53
|
+
|
|
54
|
+
// 2. 标签文字溢出
|
|
55
|
+
stage.querySelectorAll('[data-jtext], .jtrunc').forEach((t) => {
|
|
56
|
+
if (t.scrollWidth > t.clientWidth + 1) warnings.push(`标签文字溢出:${name(t)} 的文字「${(t.textContent ?? '').slice(0, 40)}」显示不全`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// 3. Toast 等浮层(pointer-events:none,elementFromPoint 看不到)与可交互元素按几何相交检测
|
|
60
|
+
const toasts = Array.from(stage.querySelectorAll('[data-jtoast]')).map((t) => t.getBoundingClientRect());
|
|
61
|
+
if (toasts.length) {
|
|
62
|
+
els.forEach((el, i) => {
|
|
63
|
+
const e = elements[i];
|
|
64
|
+
if (!el.hasAttribute('data-jinteractive') || !e.visible) return;
|
|
65
|
+
if (dialogOpen && !el.closest('[role="dialog"]')) return; // 被弹窗遮罩盖住的背景本来就不可操作
|
|
66
|
+
const r = el.getBoundingClientRect();
|
|
67
|
+
if (!toasts.some((b) => overlap(b, r))) return;
|
|
68
|
+
warnings.push(e.id === args.nextTargetId ? `Toast 遮挡了下一步操作的目标:${name(el)}` : `Toast 覆盖了可交互元素:${name(el)}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. 字体
|
|
73
|
+
// 主题用到手写字体时(--jp-font 含 Excalifont)检查它们已加载
|
|
74
|
+
if (/Excalifont|Xiaolai/.test(getComputedStyle(stage).fontFamily)) {
|
|
75
|
+
const text = stage.innerText || '中文';
|
|
76
|
+
if (!document.fonts.check('16px "Joplo Excalifont"', text) || !document.fonts.check('16px "Joplo Xiaolai"', text)) warnings.push('字体未加载:手写字体(Excalifont / Xiaolai)尚未就绪,截图可能使用了后备字体');
|
|
77
|
+
}
|
|
78
|
+
const failed = Array.from(document.fonts).filter((f) => f.status === 'error');
|
|
79
|
+
if (failed.length) warnings.push(`字体未加载:${failed.length} 个字体文件加载失败(${failed[0].family})`);
|
|
80
|
+
|
|
81
|
+
return { elements, warnings };
|
|
82
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Diagnostic, type Project } from '@joplo-app/core';
|
|
2
|
+
import { discoverFiles } from '@joplo-app/core/node';
|
|
3
|
+
import type { ViteDevServer } from 'vite';
|
|
4
|
+
export type CompileOutcome = {
|
|
5
|
+
/** 本次编译成功的 IR;失败时为 null。 */
|
|
6
|
+
project: Project | null;
|
|
7
|
+
diagnostics: Diagnostic[];
|
|
8
|
+
/** joplo.config 的 home(看板与发布站的首页路径)。 */
|
|
9
|
+
home: string | null;
|
|
10
|
+
};
|
|
11
|
+
export declare function compileProject(server: ViteDevServer, projectDir: string, mode: 'dev' | 'publish'): Promise<CompileOutcome>;
|
|
12
|
+
/** 让项目源文件在下次载入时重新转译、重新求值。 */
|
|
13
|
+
export declare function invalidateProjectModules(server: ViteDevServer, projectDir: string): void;
|
|
14
|
+
export { discoverFiles };
|