@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/project.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用 Vite 的 ssrLoadModule 载入项目源文件,交给 core 的 loadAuthoring + compile。
|
|
3
|
+
* dev(看板、截图)与 build(发布站)共用这一条路径。
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { compile } from '@joplo-app/core';
|
|
7
|
+
import { discoverFiles, loadAuthoring } from '@joplo-app/core/node';
|
|
8
|
+
import { isProjectSource } from "./vite-shared.js";
|
|
9
|
+
const LOC_IN_MESSAGE = /((?:[\w@.-]+\/)*[\w@.-]+\.(?:tsx|ts|jsx|js|mts)):(\d+):(\d+)/;
|
|
10
|
+
/** 运行时错误(模块求值时抛出):从调用栈里找第一处项目文件位置。 */
|
|
11
|
+
function stackLoc(projectDir, e) {
|
|
12
|
+
const stack = e instanceof Error ? (e.stack ?? '') : '';
|
|
13
|
+
for (const line of stack.split('\n')) {
|
|
14
|
+
const m = /\(?((?:file:\/\/)?\/[^():\s]+):(\d+):(\d+)\)?\s*$/.exec(line);
|
|
15
|
+
if (!m)
|
|
16
|
+
continue;
|
|
17
|
+
const file = decodeURIComponent(m[1].replace(/^file:\/\//, ''));
|
|
18
|
+
if (isProjectSource(projectDir, file))
|
|
19
|
+
return `${path.relative(projectDir, file).split(path.sep).join('/')}:${m[2]}:${m[3]}`;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export async function compileProject(server, projectDir, mode) {
|
|
24
|
+
const errorAt = new Map(); // 载入失败的文件(绝对路径)→ 精确位置
|
|
25
|
+
const importer = async (abs) => {
|
|
26
|
+
try {
|
|
27
|
+
return (await server.ssrLoadModule(abs));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
31
|
+
const m = LOC_IN_MESSAGE.exec(msg);
|
|
32
|
+
const at = m ? `${m[1]}:${m[2]}:${m[3]}` : stackLoc(projectDir, e);
|
|
33
|
+
if (at)
|
|
34
|
+
errorAt.set(abs, at);
|
|
35
|
+
throw e instanceof Error ? e : new Error(msg);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const loaded = await loadAuthoring(projectDir, importer);
|
|
39
|
+
const rel = (abs) => path.relative(projectDir, abs).split(path.sep).join('/');
|
|
40
|
+
const byRel = new Map([...errorAt].map(([abs, at]) => [rel(abs), at]));
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const diagnostics = [];
|
|
43
|
+
for (const d of loaded.diagnostics) {
|
|
44
|
+
let out = d;
|
|
45
|
+
if (d.code === 'LOAD_FAILED' && d.at) {
|
|
46
|
+
const file = d.at.replace(/:\d+:\d+$/, '');
|
|
47
|
+
const precise = byRel.get(file);
|
|
48
|
+
if (precise)
|
|
49
|
+
out = {
|
|
50
|
+
...d,
|
|
51
|
+
at: precise,
|
|
52
|
+
// 位置已在 at 里,说明只保留第一行的原因
|
|
53
|
+
message: d.message.replace(/\x1b\[[0-9;]*m/g, '').split('\n')[0].replace(`载入失败:${precise} `, '载入失败:'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const key = `${out.code}|${out.at}|${out.message}`;
|
|
57
|
+
if (seen.has(key))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(key);
|
|
60
|
+
diagnostics.push(out);
|
|
61
|
+
}
|
|
62
|
+
if (!loaded.authoring)
|
|
63
|
+
return { project: null, diagnostics, home: null };
|
|
64
|
+
const home = loaded.authoring.config.home ?? null;
|
|
65
|
+
if (diagnostics.some((d) => d.severity === 'error'))
|
|
66
|
+
return { project: null, diagnostics, home };
|
|
67
|
+
const r = compile(loaded.authoring, mode);
|
|
68
|
+
return { project: r.project, diagnostics: [...diagnostics, ...r.diagnostics], home };
|
|
69
|
+
}
|
|
70
|
+
/** 让项目源文件在下次载入时重新转译、重新求值。 */
|
|
71
|
+
export function invalidateProjectModules(server, projectDir) {
|
|
72
|
+
for (const env of Object.values(server.environments)) {
|
|
73
|
+
const graph = env.moduleGraph;
|
|
74
|
+
for (const mod of graph.idToModuleMap.values())
|
|
75
|
+
if (mod.file && isProjectSource(projectDir, mod.file))
|
|
76
|
+
graph.invalidateModule(mod);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export { discoverFiles };
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Browser } from 'playwright';
|
|
2
|
+
import type { RenderOptions, RenderResult } from './contract.js';
|
|
3
|
+
export type PlaywrightModule = typeof import('playwright');
|
|
4
|
+
/** 按需载入 Playwright;没有安装时抛出带安装命令的 RENDER_FAILED。 */
|
|
5
|
+
export declare function loadPlaywright(importer?: () => Promise<PlaywrightModule>): Promise<PlaywrightModule>;
|
|
6
|
+
/** 浏览器启动失败时的一行提示。 */
|
|
7
|
+
export declare function launchHint(e: unknown): string;
|
|
8
|
+
export declare function launch({ chromium }: PlaywrightModule): Promise<Browser>;
|
|
9
|
+
export declare function renderStep(opts: RenderOptions): Promise<RenderResult>;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderStep:用随机端口启动一次性的 dev 服务(只编译一次、不监听),Playwright 打开渲染路由,
|
|
3
|
+
* 截取舞台并返回元素索引(带 at)与渲染告警:遮挡、溢出、Toast 重叠、字体(PRD 8.8 / AC-22)。
|
|
4
|
+
* 页面读取的 IR 与这里用来计算位置、隐藏元素的 IR 是同一份(无 TOCTOU)。
|
|
5
|
+
*
|
|
6
|
+
* Playwright 是可选依赖(D36):只在这里按需载入;没装或没有浏览器时给出安装命令,dev/check/build 不受影响。
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { JoploError, SUBTITLE_HEIGHT, VIEWPORT_SIZE, displaySceneId, findNode, isNodeDisabled, nodeLabel, positionAfterStep, resolveProps, stateAt, walkNodes } from '@joplo-app/core';
|
|
11
|
+
import { createDev } from "./dev.js";
|
|
12
|
+
import { realProjectDir } from "./vite-shared.js";
|
|
13
|
+
const STAGE_SIZE = VIEWPORT_SIZE;
|
|
14
|
+
const NOT_NEEDED = 'dev、check、build 不需要它。';
|
|
15
|
+
const isMissingModule = (e) => {
|
|
16
|
+
const code = e?.code;
|
|
17
|
+
return (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') && /playwright/.test(e instanceof Error ? e.message : String(e));
|
|
18
|
+
};
|
|
19
|
+
/** 按需载入 Playwright;没有安装时抛出带安装命令的 RENDER_FAILED。 */
|
|
20
|
+
export async function loadPlaywright(importer = () => import('playwright')) {
|
|
21
|
+
try {
|
|
22
|
+
return await importer();
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (!isMissingModule(e))
|
|
26
|
+
throw e;
|
|
27
|
+
throw new JoploError('RENDER_FAILED', `截图需要 Playwright 和 Chromium,当前没有安装。运行:npm i -D playwright && npx playwright install chromium(约 150 MB)。${NOT_NEEDED}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** 浏览器启动失败时的一行提示。 */
|
|
31
|
+
export function launchHint(e) {
|
|
32
|
+
const first = (e instanceof Error ? e.message : String(e)).split('\n')[0].trim();
|
|
33
|
+
return `截图需要 Chromium,无法启动浏览器(${first})。运行:npx playwright install chromium(约 150 MB)。${NOT_NEEDED}`;
|
|
34
|
+
}
|
|
35
|
+
export async function launch({ chromium }) {
|
|
36
|
+
try {
|
|
37
|
+
return await chromium.launch({ headless: true });
|
|
38
|
+
}
|
|
39
|
+
catch (first) {
|
|
40
|
+
try {
|
|
41
|
+
return await chromium.launch({ headless: true, channel: 'chrome' });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new JoploError('RENDER_FAILED', launchHint(first));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 解析符号链接后的绝对路径;不存在的部分按原样拼接(以最近的已存在祖先为基准)。 */
|
|
49
|
+
function realish(p) {
|
|
50
|
+
const abs = path.resolve(p);
|
|
51
|
+
const rest = [];
|
|
52
|
+
let cur = abs;
|
|
53
|
+
for (;;) {
|
|
54
|
+
try {
|
|
55
|
+
return path.join(fs.realpathSync(cur), ...rest);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
const parent = path.dirname(cur);
|
|
59
|
+
if (parent === cur)
|
|
60
|
+
return abs;
|
|
61
|
+
rest.unshift(path.basename(cur));
|
|
62
|
+
cur = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** 截图不得覆盖项目源文件或 .joplo/(即使传了 overwrite)。 */
|
|
67
|
+
function assertNotProjectData(projectDir, out) {
|
|
68
|
+
const fold = (p) => (process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p);
|
|
69
|
+
const o = fold(realish(out));
|
|
70
|
+
const dir = fold(realish(projectDir));
|
|
71
|
+
const dataDir = path.join(dir, '.joplo');
|
|
72
|
+
if (o === dataDir || o.startsWith(dataDir + path.sep))
|
|
73
|
+
throw new JoploError('USAGE', `不能写入项目数据:${out}(.joplo/ 由 Joplo 管理)`);
|
|
74
|
+
if (/\.(tsx?|jsx?|mts)$/.test(o) && o.startsWith(dir + path.sep))
|
|
75
|
+
throw new JoploError('USAGE', `不能用截图覆盖项目源文件:${out}`);
|
|
76
|
+
}
|
|
77
|
+
export async function renderStep(opts) {
|
|
78
|
+
const projectDir = realProjectDir(opts.projectDir);
|
|
79
|
+
const out = path.resolve(opts.out);
|
|
80
|
+
assertNotProjectData(projectDir, out);
|
|
81
|
+
if (fs.existsSync(out) && !opts.overwrite)
|
|
82
|
+
throw new JoploError('USAGE', `目标文件已存在:${out}。如需覆盖请传 overwrite: true(CLI:--overwrite)`);
|
|
83
|
+
// 先确认 Playwright 可用,再启动编译服务(缺失时尽快失败)
|
|
84
|
+
const playwright = await loadPlaywright();
|
|
85
|
+
const server = await createDev({ projectDir, port: 0, watch: false, log: () => undefined });
|
|
86
|
+
let browser = null;
|
|
87
|
+
try {
|
|
88
|
+
const payload = server.payload();
|
|
89
|
+
const project = payload.project;
|
|
90
|
+
if (!project) {
|
|
91
|
+
const errs = payload.diagnostics.filter((d) => d.severity === 'error');
|
|
92
|
+
throw new JoploError('INVALID_SCHEMA', `项目编译失败:${errs.map((d) => `${d.at ?? ''} ${d.message}`).join(';')}`, { diagnostics: payload.diagnostics });
|
|
93
|
+
}
|
|
94
|
+
const sc = project.scenarios[opts.scenarioId];
|
|
95
|
+
if (!sc)
|
|
96
|
+
throw new JoploError('NOT_FOUND', `场景不存在:${opts.scenarioId}(可用:${Object.keys(project.scenarios).join('、') || '无'})`);
|
|
97
|
+
const position = opts.stepId ? positionAfterStep(project, sc.id, opts.stepId) : 0;
|
|
98
|
+
const state = stateAt(project, sc.id, position); // 回放失败会抛出 PLAYBACK_FAILED
|
|
99
|
+
// 有层级的方案:截图与元素索引用这一步导演层的画面(每层一个独立画面)
|
|
100
|
+
const scene = project.scenes[displaySceneId(project, sc.id, position)];
|
|
101
|
+
const theme = opts.theme ?? project.theme ?? 'sketch';
|
|
102
|
+
const next = state.completed ? undefined : sc.steps[position];
|
|
103
|
+
const nextTargetId = next && 'target' in next.action ? next.action.target : null;
|
|
104
|
+
const stage = STAGE_SIZE[scene.viewport === 'mobile' ? 'mobile' : 'desktop'];
|
|
105
|
+
// 舞台正下方是字幕区(与演示页一致):页面高度加上字幕区,截图截舞台 + 字幕区
|
|
106
|
+
const viewport = opts.viewport ?? { width: stage.width, height: stage.height + SUBTITLE_HEIGHT };
|
|
107
|
+
browser = await launch(playwright);
|
|
108
|
+
const page = await browser.newPage({ viewport, deviceScaleFactor: opts.scale ?? 1, reducedMotion: 'reduce' });
|
|
109
|
+
// D44:舞台宽度固定为页面自己的 viewport;指定 viewport 时它只是截图尺寸(舞台按比例放进去)
|
|
110
|
+
const q = new URLSearchParams({ render: '1', project: server.projectId, scenario: sc.id, position: String(position), theme, ...(opts.viewport ? { w: String(opts.viewport.width), h: String(opts.viewport.height) } : {}) });
|
|
111
|
+
await page.goto(`${server.url}?${q}`, { waitUntil: 'domcontentloaded' });
|
|
112
|
+
await page.waitForFunction(() => window.__JOPLO_READY__ === true, null, { timeout: 60000 });
|
|
113
|
+
const err = await page.evaluate(() => window.__JOPLO_ERROR__ ?? null);
|
|
114
|
+
if (err)
|
|
115
|
+
throw new JoploError('RENDER_FAILED', `渲染路由报告错误:${err}`);
|
|
116
|
+
const report = (await page.evaluate(`(${COLLECT_REPORT})(${JSON.stringify({ nextTargetId, theme })})`));
|
|
117
|
+
const png = await page.locator(opts.viewport ? '[data-jshot]' : '[data-jrender]').screenshot({ type: 'png', animations: 'disabled' });
|
|
118
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
119
|
+
if (opts.overwrite)
|
|
120
|
+
fs.writeFileSync(out, png);
|
|
121
|
+
else
|
|
122
|
+
fs.writeFileSync(out, png, { flag: 'wx' });
|
|
123
|
+
// DOM 中没有的节点(隐藏/未渲染)也列入索引,visible=false
|
|
124
|
+
const seen = new Set(report.elements.map((e) => e.id));
|
|
125
|
+
const hidden = [];
|
|
126
|
+
walkNodes(scene.root, (node) => {
|
|
127
|
+
if (seen.has(node.id) || node.type === '#text')
|
|
128
|
+
return;
|
|
129
|
+
hidden.push({
|
|
130
|
+
id: node.id,
|
|
131
|
+
type: node.type,
|
|
132
|
+
label: nodeLabel({ type: node.type, props: resolveProps(node, state), children: node.children }),
|
|
133
|
+
visible: false,
|
|
134
|
+
disabled: isNodeDisabled(node, state),
|
|
135
|
+
at: node.source ?? null,
|
|
136
|
+
box: { x: 0, y: 0, width: 0, height: 0 },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
const elements = [...report.elements, ...hidden];
|
|
140
|
+
const warnings = [...report.warnings];
|
|
141
|
+
if (nextTargetId && findNode(scene, nextTargetId) && !elements.find((e) => e.id === nextTargetId && e.visible))
|
|
142
|
+
warnings.push(`下一步操作的目标 ${nextTargetId} 没有显示在画面中`);
|
|
143
|
+
return { path: out, sceneId: scene.id, position, elements, warnings, scale: opts.scale ?? 1 };
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await browser?.close().catch(() => undefined);
|
|
147
|
+
await server.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const COLLECT_REPORT = fs.readFileSync(path.join(import.meta.dirname, 'page-report.js'), 'utf8').replace(/^\/\*[\s\S]*?\*\/\s*/, '');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type Project, type Selection } from '@joplo-app/core';
|
|
3
|
+
/** 浏览器提交的选中(服务端补全其余字段)。 */
|
|
4
|
+
export declare const SelectionInput: z.ZodObject<{
|
|
5
|
+
optionId: z.ZodNullable<z.ZodString>;
|
|
6
|
+
scenarioId: z.ZodNullable<z.ZodString>;
|
|
7
|
+
position: z.ZodNumber;
|
|
8
|
+
sceneId: z.ZodString;
|
|
9
|
+
nodeId: z.ZodNullable<z.ZodString>;
|
|
10
|
+
stepId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
}, z.core.$strict>;
|
|
12
|
+
export type SelectionInput = z.infer<typeof SelectionInput>;
|
|
13
|
+
export declare const SelectionBody: z.ZodObject<{
|
|
14
|
+
selection: z.ZodNullable<z.ZodObject<{
|
|
15
|
+
optionId: z.ZodNullable<z.ZodString>;
|
|
16
|
+
scenarioId: z.ZodNullable<z.ZodString>;
|
|
17
|
+
position: z.ZodNumber;
|
|
18
|
+
sceneId: z.ZodString;
|
|
19
|
+
nodeId: z.ZodNullable<z.ZodString>;
|
|
20
|
+
stepId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
21
|
+
}, z.core.$strict>>;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
/** 对照当前 IR 校验并补全;返回 Selection 或问题描述。 */
|
|
24
|
+
export declare function resolveSelection(project: Project, input: SelectionInput, now?: Date): {
|
|
25
|
+
ok: true;
|
|
26
|
+
selection: Selection;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
problem: string;
|
|
30
|
+
};
|
|
31
|
+
/** 临时文件 + rename 的原子写入。 */
|
|
32
|
+
export declare function writeFileAtomic(file: string, data: string): void;
|
|
33
|
+
export declare const selectionFile: (projectDir: string) => string;
|
|
34
|
+
export declare function writeSelection(projectDir: string, sel: Selection | null): void;
|
|
35
|
+
export declare function readSelection(projectDir: string): Selection | null;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 看板写入 `.joplo/selection.json`(core 的 Selection 类型)。
|
|
3
|
+
*
|
|
4
|
+
* 流向:看板 DOM 的 data-jat → 浏览器提交「选中了谁」→ 这里对照当前 IR 校验,并由服务端
|
|
5
|
+
* 重新计算 at / label / route / featureId(不信任浏览器给的 at)→ 原子写入文件 → `joplo selection` 读取。
|
|
6
|
+
*
|
|
7
|
+
* 不变量:每次写接口返回后,selection.json 与界面显示的选中一致。被拒绝(409)时服务端写入 null,
|
|
8
|
+
* 并在响应里返回 `current: null`,看板据此清除选中并明确提示。
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { findNode, nodeLabel, stateAt, walkNodes } from '@joplo-app/core';
|
|
15
|
+
/** 浏览器提交的选中(服务端补全其余字段)。 */
|
|
16
|
+
export const SelectionInput = z.strictObject({
|
|
17
|
+
optionId: z.string().min(1).max(256).nullable(),
|
|
18
|
+
scenarioId: z.string().min(1).max(256).nullable(),
|
|
19
|
+
position: z.number().int().nonnegative().max(10000),
|
|
20
|
+
sceneId: z.string().min(1).max(256),
|
|
21
|
+
nodeId: z.string().min(1).max(128).nullable(),
|
|
22
|
+
/** 只选中步骤(审阅模式点步骤芯片)时给出。 */
|
|
23
|
+
stepId: z.string().min(1).max(300).nullable().optional(),
|
|
24
|
+
});
|
|
25
|
+
export const SelectionBody = z.strictObject({ selection: SelectionInput.nullable() });
|
|
26
|
+
/** 对照当前 IR 校验并补全;返回 Selection 或问题描述。 */
|
|
27
|
+
export function resolveSelection(project, input, now = new Date()) {
|
|
28
|
+
let sceneId = input.sceneId;
|
|
29
|
+
let stepId = null;
|
|
30
|
+
let featureId = null;
|
|
31
|
+
const optionId = input.optionId;
|
|
32
|
+
if (optionId !== null) {
|
|
33
|
+
const opt = project.options[optionId];
|
|
34
|
+
if (!opt)
|
|
35
|
+
return { ok: false, problem: `方案 ${optionId} 不存在` };
|
|
36
|
+
featureId = opt.featureId;
|
|
37
|
+
}
|
|
38
|
+
if (input.scenarioId !== null) {
|
|
39
|
+
const sc = project.scenarios[input.scenarioId];
|
|
40
|
+
if (!sc)
|
|
41
|
+
return { ok: false, problem: `场景 ${input.scenarioId} 不存在` };
|
|
42
|
+
if (optionId !== sc.optionId)
|
|
43
|
+
return { ok: false, problem: `场景 ${sc.id} 不属于方案 ${optionId ?? '(无)'}` };
|
|
44
|
+
if (input.position > sc.steps.length)
|
|
45
|
+
return { ok: false, problem: `位置 ${input.position} 超出场景 ${sc.id} 的范围 0..${sc.steps.length}` };
|
|
46
|
+
try {
|
|
47
|
+
sceneId = stateAt(project, sc.id, input.position).sceneId;
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
return { ok: false, problem: `场景 ${sc.id} 无法回放到位置 ${input.position}:${e instanceof Error ? e.message : String(e)}` };
|
|
51
|
+
}
|
|
52
|
+
// 有层级的方案:用户看的是某一层的画面(各层共享同一份状态),也接受
|
|
53
|
+
const layerScenes = Object.values(project.options[sc.optionId]?.layers ?? {}).map((l) => l.sceneId);
|
|
54
|
+
if (layerScenes.includes(input.sceneId))
|
|
55
|
+
sceneId = input.sceneId;
|
|
56
|
+
if (input.sceneId !== sceneId)
|
|
57
|
+
return { ok: false, problem: `位置 ${input.position} 的画面是 ${sceneId},不是 ${input.sceneId}` };
|
|
58
|
+
stepId = sc.steps[input.position - 1]?.id ?? null;
|
|
59
|
+
if (input.stepId) {
|
|
60
|
+
if (!sc.steps.some((s) => s.id === input.stepId))
|
|
61
|
+
return { ok: false, problem: `步骤 ${input.stepId} 不在场景 ${sc.id} 中` };
|
|
62
|
+
stepId = input.stepId;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else if (input.position !== 0 || input.stepId)
|
|
66
|
+
return { ok: false, problem: '没有场景时位置必须为 0,且不能选中步骤' };
|
|
67
|
+
const scene = project.scenes[sceneId];
|
|
68
|
+
if (!scene)
|
|
69
|
+
return { ok: false, problem: `画面 ${sceneId} 不存在` };
|
|
70
|
+
if (input.scenarioId === null && optionId !== null && scene.optionId !== optionId && scene.optionId !== null)
|
|
71
|
+
return { ok: false, problem: `画面 ${sceneId} 不属于方案 ${optionId}` };
|
|
72
|
+
let at = null;
|
|
73
|
+
let label = null;
|
|
74
|
+
if (input.nodeId) {
|
|
75
|
+
const hit = findNode(scene, input.nodeId);
|
|
76
|
+
if (!hit)
|
|
77
|
+
return { ok: false, problem: `元素 ${input.nodeId} 不在画面 ${sceneId} 中` };
|
|
78
|
+
at = hit.node.source ?? null;
|
|
79
|
+
label = nodeLabel(hit.node); // Mantine 输入组件直接带 label 属性
|
|
80
|
+
}
|
|
81
|
+
else if (input.stepId && input.scenarioId) {
|
|
82
|
+
const st = project.scenarios[input.scenarioId].steps.find((s) => s.id === input.stepId);
|
|
83
|
+
at = st.source ?? null;
|
|
84
|
+
label = st.caption;
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
return { ok: false, problem: '选中必须指向一个元素或一个步骤' };
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
selection: {
|
|
91
|
+
featureId,
|
|
92
|
+
optionId,
|
|
93
|
+
scenarioId: input.scenarioId,
|
|
94
|
+
position: input.position,
|
|
95
|
+
stepId,
|
|
96
|
+
sceneId,
|
|
97
|
+
route: scene.route,
|
|
98
|
+
nodeId: input.nodeId,
|
|
99
|
+
at,
|
|
100
|
+
label,
|
|
101
|
+
selectedAt: now.toISOString(),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** 临时文件 + rename 的原子写入。 */
|
|
106
|
+
export function writeFileAtomic(file, data) {
|
|
107
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
108
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
109
|
+
fs.writeFileSync(tmp, data);
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
}
|
|
112
|
+
export const selectionFile = (projectDir) => path.join(projectDir, '.joplo', 'selection.json');
|
|
113
|
+
export function writeSelection(projectDir, sel) {
|
|
114
|
+
writeFileAtomic(selectionFile(projectDir), JSON.stringify(sel, null, 2) + '\n');
|
|
115
|
+
}
|
|
116
|
+
export function readSelection(projectDir) {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(fs.readFileSync(selectionFile(projectDir), 'utf8'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Plugin } from 'vite';
|
|
2
|
+
import { type WebAssets } from './web-assets.js';
|
|
3
|
+
export { kitAliases, locateWebAssets } from './web-assets.js';
|
|
4
|
+
/** 项目目录统一用真实路径(macOS 的 /tmp → /private/tmp),保证 jsxDEV 与调用栈给出的路径能被 relSource 转成相对路径。 */
|
|
5
|
+
export declare function realProjectDir(dir: string): string;
|
|
6
|
+
/** 不参与编译的项目子目录。 */
|
|
7
|
+
export declare const IGNORED_DIRS: string[];
|
|
8
|
+
export declare function isProjectSource(projectDir: string, file: string): boolean;
|
|
9
|
+
/** 语法错误的位置:从转译器报错里取 `文件:行:列`。 */
|
|
10
|
+
export type SourceLoc = {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
column: number;
|
|
14
|
+
};
|
|
15
|
+
export declare class ProjectSyntaxError extends Error {
|
|
16
|
+
readonly loc: SourceLoc;
|
|
17
|
+
constructor(message: string, loc: SourceLoc);
|
|
18
|
+
}
|
|
19
|
+
export declare function parseErrorLoc(message: string, file: string): {
|
|
20
|
+
line: number;
|
|
21
|
+
column: number;
|
|
22
|
+
} | null;
|
|
23
|
+
/** 作者 className 的 CSS 入口:追加每个项目目录为 Tailwind @source(看板进程同时服务多个项目)。 */
|
|
24
|
+
export declare function cssSourcePlugin(dirs: () => string[], web?: WebAssets): Plugin;
|
|
25
|
+
/**
|
|
26
|
+
* 转译项目源文件(TSX → 收集数据的 JS);把 Tailwind @source 指向项目目录。
|
|
27
|
+
* dist 形态下 CSS 入口是 @joplo-app/web/dist/project.css,其中的 `@import "tailwindcss"` 改为从 server 的依赖解析。
|
|
28
|
+
*/
|
|
29
|
+
export declare function joploProjectPlugin(projectDir: string, web?: WebAssets): Plugin;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev / build / render 共用的 Vite 配置片段。
|
|
3
|
+
*
|
|
4
|
+
* - 看板与发布站的界面源码在 packages/web(Vite root)。
|
|
5
|
+
* - 项目源文件(作者的 TSX)只在服务端经 ssrLoadModule 载入:用 automatic JSX、
|
|
6
|
+
* jsxImportSource '@joplo-app/kit'、development=true 转译,jsxDEV 因此拿到 文件:行:列。
|
|
7
|
+
* - Tailwind 需要编译项目源文件里出现的 className:把项目目录追加为 index.css 的 @source。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { transformWithOxc } from 'vite';
|
|
12
|
+
import { cssEntry, locateWebAssets, tailwindIndexCss } from "./web-assets.js";
|
|
13
|
+
export { kitAliases, locateWebAssets } from "./web-assets.js";
|
|
14
|
+
/** 项目目录统一用真实路径(macOS 的 /tmp → /private/tmp),保证 jsxDEV 与调用栈给出的路径能被 relSource 转成相对路径。 */
|
|
15
|
+
export function realProjectDir(dir) {
|
|
16
|
+
const abs = path.resolve(dir);
|
|
17
|
+
try {
|
|
18
|
+
return fs.realpathSync(abs);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return abs;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** 不参与编译的项目子目录。 */
|
|
25
|
+
export const IGNORED_DIRS = ['.joplo', 'dist', 'node_modules', '.git'];
|
|
26
|
+
export function isProjectSource(projectDir, file) {
|
|
27
|
+
if (!file.startsWith(projectDir + path.sep))
|
|
28
|
+
return false;
|
|
29
|
+
const rel = file.slice(projectDir.length + 1);
|
|
30
|
+
const first = rel.split(path.sep)[0] ?? '';
|
|
31
|
+
return !IGNORED_DIRS.includes(first);
|
|
32
|
+
}
|
|
33
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
34
|
+
export class ProjectSyntaxError extends Error {
|
|
35
|
+
loc;
|
|
36
|
+
constructor(message, loc) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'ProjectSyntaxError';
|
|
39
|
+
this.loc = loc;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function parseErrorLoc(message, file) {
|
|
43
|
+
const text = stripAnsi(message);
|
|
44
|
+
const base = path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
45
|
+
const m = new RegExp(`${base}:(\\d+):(\\d+)`).exec(text) ?? /:(\d+):(\d+)/.exec(text);
|
|
46
|
+
return m ? { line: Number(m[1]), column: Number(m[2]) } : null;
|
|
47
|
+
}
|
|
48
|
+
/** 作者 className 的 CSS 入口:追加每个项目目录为 Tailwind @source(看板进程同时服务多个项目)。 */
|
|
49
|
+
export function cssSourcePlugin(dirs, web = locateWebAssets()) {
|
|
50
|
+
const entry = cssEntry(web);
|
|
51
|
+
return {
|
|
52
|
+
name: 'joplo:css-sources',
|
|
53
|
+
enforce: 'pre',
|
|
54
|
+
transform(code, id) {
|
|
55
|
+
if (id.split('?')[0] !== entry)
|
|
56
|
+
return;
|
|
57
|
+
const css = web.mode === 'dist' ? code.replace(/@import\s+["']tailwindcss["']/, `@import ${JSON.stringify(tailwindIndexCss())}`) : code;
|
|
58
|
+
return { code: `${css}\n${dirs().map((d) => `@source ${JSON.stringify(d)};`).join('\n')}\n`, map: null };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 转译项目源文件(TSX → 收集数据的 JS);把 Tailwind @source 指向项目目录。
|
|
64
|
+
* dist 形态下 CSS 入口是 @joplo-app/web/dist/project.css,其中的 `@import "tailwindcss"` 改为从 server 的依赖解析。
|
|
65
|
+
*/
|
|
66
|
+
export function joploProjectPlugin(projectDir, web = locateWebAssets()) {
|
|
67
|
+
const entry = cssEntry(web);
|
|
68
|
+
return {
|
|
69
|
+
name: 'joplo:project',
|
|
70
|
+
enforce: 'pre',
|
|
71
|
+
async transform(code, id) {
|
|
72
|
+
const file = id.split('?')[0];
|
|
73
|
+
if (file === entry) {
|
|
74
|
+
// Tailwind v4:@source 绝对路径;JSON.stringify 负责转义引号与反斜杠
|
|
75
|
+
const css = web.mode === 'dist' ? code.replace(/@import\s+["']tailwindcss["']/, `@import ${JSON.stringify(tailwindIndexCss())}`) : code;
|
|
76
|
+
return { code: `${css}\n@source ${JSON.stringify(projectDir)};\n`, map: null };
|
|
77
|
+
}
|
|
78
|
+
if (!isProjectSource(projectDir, file) || !/\.(tsx|ts|jsx|js|mts)$/.test(file))
|
|
79
|
+
return;
|
|
80
|
+
const lang = file.endsWith('.tsx') ? 'tsx' : file.endsWith('.jsx') ? 'jsx' : file.endsWith('.js') ? 'js' : 'ts';
|
|
81
|
+
try {
|
|
82
|
+
const r = await transformWithOxc(code, file, {
|
|
83
|
+
lang,
|
|
84
|
+
jsx: { runtime: 'automatic', importSource: '@joplo-app/kit', development: true },
|
|
85
|
+
sourcemap: true,
|
|
86
|
+
});
|
|
87
|
+
return { code: r.code, map: r.map ?? null };
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
91
|
+
const errs = e.errors;
|
|
92
|
+
const first = stripAnsi(errs?.[0]?.message ?? raw);
|
|
93
|
+
const loc = parseErrorLoc(first, file) ?? { line: 1, column: 1 };
|
|
94
|
+
const rel = path.relative(projectDir, file).split(path.sep).join('/');
|
|
95
|
+
const brief = (first.split('\n')[0] ?? '').replace(/^\s*\[[A-Z_]+\]\s*/, '').trim();
|
|
96
|
+
throw new ProjectSyntaxError(`${rel}:${loc.line}:${loc.column} 语法错误:${brief || '无法解析'}`, { file, ...loc });
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Alias } from 'vite';
|
|
2
|
+
export type SourceWeb = {
|
|
3
|
+
mode: 'source';
|
|
4
|
+
webDir: string;
|
|
5
|
+
indexCss: string;
|
|
6
|
+
kitSrc: string;
|
|
7
|
+
};
|
|
8
|
+
export type DistWeb = {
|
|
9
|
+
mode: 'dist';
|
|
10
|
+
webDir: string;
|
|
11
|
+
/** 看板 index.html 与发布站 site.html 所在目录(Vite 开发服务的 root)。 */
|
|
12
|
+
appDir: string;
|
|
13
|
+
/** 发布站需要复制的文件(相对 appDir)。 */
|
|
14
|
+
siteFiles: string[];
|
|
15
|
+
/** 作者 className 的 Tailwind 入口。 */
|
|
16
|
+
projectCss: string;
|
|
17
|
+
version: string;
|
|
18
|
+
};
|
|
19
|
+
export type WebAssets = SourceWeb | DistWeb;
|
|
20
|
+
/** 读取并校验 @joplo-app/web 的 dist/manifest.json。 */
|
|
21
|
+
export declare function readWebDist(webDir: string): DistWeb;
|
|
22
|
+
/** 看板与发布站资源。结果按进程缓存(env 只在首次调用时生效;测试可传入 env 绕过缓存)。 */
|
|
23
|
+
export declare function locateWebAssets(env?: Record<string, string | undefined>): WebAssets;
|
|
24
|
+
/** 作者 className 的 CSS 入口(source:packages/web/src/index.css;dist:@joplo-app/web/dist/project.css)。 */
|
|
25
|
+
export declare const cssEntry: (web: WebAssets) => string;
|
|
26
|
+
/** Tailwind 的 index.css,从 server 自己的依赖解析(pnpm 严格布局下 @joplo-app/web 没有 tailwindcss 依赖)。 */
|
|
27
|
+
export declare function tailwindIndexCss(): string;
|
|
28
|
+
/** 从 dir 起向上找 node_modules/<name>/package.json(与 Node 的包解析顺序一致,不依赖 exports)。 */
|
|
29
|
+
export declare function findPackageJson(dir: string, name: string): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* 项目源文件里的 `@joplo-app/kit` 怎样解析:
|
|
32
|
+
* - source:指向仓库里的 kit 源码(项目可在任意目录,不需要自己的 node_modules);`@/` 指向 web 源码。
|
|
33
|
+
* - dist:项目装了 @joplo-app/kit 就按项目依赖解析(与 `joplo check` 用的 tsx 一致);没装则退回 CLI 自带的 kit。
|
|
34
|
+
*/
|
|
35
|
+
export declare function kitAliases(web: WebAssets, projectDir: string): Alias[];
|