@lemonppt/cli 0.1.8 → 0.2.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/SKILL.md +259 -97
- package/agents/codex.yaml +99 -0
- package/agents/cursor.yaml +47 -0
- package/agents/openai.yaml +176 -0
- package/dist/cli.js +285 -2
- package/dist/index.d.ts +149 -1
- package/dist/index.js +473 -14
- package/dist/install-skill.d.ts +2 -0
- package/dist/install-skill.js +69 -20
- package/package.json +7 -6
package/dist/index.js
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
// Copyright (c) 2026 lemonforme
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
4
|
import { generateGoal } from '@lemonppt/agent-prompts';
|
|
5
|
-
import { normalizeDeckGoal } from '@lemonppt/core';
|
|
6
|
-
import { exportDeckToPdf, exportDeckToPptx, renderDeck, } from '@lemonppt/renderer';
|
|
7
|
-
import { getTheme } from '@lemonppt/themes';
|
|
5
|
+
import { normalizeDeckGoal, preprocessAgentGoal, validateDeckGoal, validateDeckGoalContent, validateSlideCount } from '@lemonppt/core';
|
|
6
|
+
import { exportDeckToPdf, exportDeckToPptx, renderDeck, renderEditorData, } from '@lemonppt/renderer';
|
|
7
|
+
import { getTheme, themes } from '@lemonppt/themes';
|
|
8
|
+
import { getLayout, getLayoutSchema, listLayoutsByRoleAndTheme } from '@lemonppt/templates';
|
|
8
9
|
import { copyFile, cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
9
11
|
import path from 'node:path';
|
|
10
12
|
import { fileURLToPath } from 'node:url';
|
|
11
13
|
function resolveTheme(themeId) {
|
|
12
|
-
const id = themeId || '
|
|
13
|
-
return getTheme(id) ? id : '
|
|
14
|
+
const id = themeId || 'theme01';
|
|
15
|
+
return getTheme(id) ? id : 'theme01';
|
|
14
16
|
}
|
|
15
|
-
async function copyThemeAssets(themeId, assetsDir) {
|
|
17
|
+
export async function copyThemeAssets(themeId, assetsDir) {
|
|
16
18
|
const theme = resolveTheme(themeId);
|
|
17
19
|
await mkdir(assetsDir, { recursive: true });
|
|
18
20
|
const cssSource = resolvePackagePath('@lemonppt/themes', 'src', theme, 'styles.css');
|
|
@@ -22,17 +24,59 @@ async function copyThemeAssets(themeId, assetsDir) {
|
|
|
22
24
|
const fontsSource = resolvePackagePath('@lemonppt/renderer', 'assets', 'fonts');
|
|
23
25
|
const fontsDest = path.join(assetsDir, 'fonts');
|
|
24
26
|
await cp(fontsSource, fontsDest, { recursive: true, force: true });
|
|
27
|
+
// 复制浏览器可执行 bundle(IIFE 格式),支持静态文件模式下结构编辑
|
|
28
|
+
const clientRenderSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client', 'client-render.js');
|
|
29
|
+
const clientRenderDest = path.join(assetsDir, 'client-render.js');
|
|
30
|
+
await copyFile(clientRenderSource, clientRenderDest);
|
|
31
|
+
// 复制 ECharts 主题脚本
|
|
32
|
+
const themeEChartsSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client', 'theme-echarts.js');
|
|
33
|
+
const themeEChartsDest = path.join(assetsDir, 'theme-echarts.js');
|
|
34
|
+
await copyFile(themeEChartsSource, themeEChartsDest);
|
|
35
|
+
// 复制 jQuery,供编辑器初始化自定义滚动条样式类
|
|
36
|
+
const jquerySource = resolvePackagePath('jquery', 'dist', 'jquery.min.js');
|
|
37
|
+
const jqueryDest = path.join(assetsDir, 'jquery.min.js');
|
|
38
|
+
await copyFile(jquerySource, jqueryDest);
|
|
39
|
+
// 复制编辑器交互脚本,供单页编辑器动态初始化
|
|
40
|
+
const editorScriptSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client', 'editor-script.js');
|
|
41
|
+
const editorScriptDest = path.join(assetsDir, 'editor-script.js');
|
|
42
|
+
await copyFile(editorScriptSource, editorScriptDest);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 把本地媒体文件复制到输出目录的 media/ 下,供 goal.json 引用。
|
|
46
|
+
*/
|
|
47
|
+
export async function stageMediaToFile(options) {
|
|
48
|
+
const outDir = options.outDir || './output';
|
|
49
|
+
const originalName = path.basename(options.filePath);
|
|
50
|
+
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
51
|
+
const mediaDir = path.join(outDir, 'media');
|
|
52
|
+
await mkdir(mediaDir, { recursive: true });
|
|
53
|
+
const localPath = path.resolve(path.join(mediaDir, safeName));
|
|
54
|
+
await copyFile(options.filePath, localPath);
|
|
55
|
+
return {
|
|
56
|
+
filename: safeName,
|
|
57
|
+
localPath,
|
|
58
|
+
relativePath: path.join('media', safeName),
|
|
59
|
+
};
|
|
25
60
|
}
|
|
26
61
|
function resolvePackagePath(pkg, ...segments) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
62
|
+
// 优先通过 package.json 定位包根(第三方 npm 包)
|
|
63
|
+
try {
|
|
64
|
+
const pkgJsonUrl = import.meta.resolve(`${pkg}/package.json`);
|
|
65
|
+
const pkgRoot = path.dirname(fileURLToPath(pkgJsonUrl));
|
|
66
|
+
return path.join(pkgRoot, ...segments);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// 工作区包可能未导出 package.json,回退到主入口的父目录的父目录
|
|
70
|
+
const mainUrl = import.meta.resolve(pkg);
|
|
71
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(mainUrl)), '..');
|
|
72
|
+
return path.join(pkgRoot, ...segments);
|
|
73
|
+
}
|
|
30
74
|
}
|
|
31
75
|
/**
|
|
32
76
|
* 生成 goal.json 并可选写入文件。
|
|
33
77
|
*/
|
|
34
78
|
export async function generateGoalToFile(options) {
|
|
35
|
-
const { input, pageCount = 8, theme = '
|
|
79
|
+
const { input, pageCount = 8, theme = 'theme01', language = 'zh', apiKey, baseUrl, model, outFile, } = options;
|
|
36
80
|
const result = await generateGoal({
|
|
37
81
|
input,
|
|
38
82
|
pageCount,
|
|
@@ -55,22 +99,50 @@ export async function generateGoalToFile(options) {
|
|
|
55
99
|
*/
|
|
56
100
|
export async function readGoalFromFile(filePath) {
|
|
57
101
|
const raw = await readFile(path.resolve(filePath), 'utf-8');
|
|
58
|
-
const parsed = JSON.parse(raw);
|
|
102
|
+
const parsed = preprocessAgentGoal(JSON.parse(raw));
|
|
59
103
|
parsed.theme = resolveTheme(parsed.theme);
|
|
60
104
|
return normalizeDeckGoal(parsed);
|
|
61
105
|
}
|
|
62
106
|
/**
|
|
63
107
|
* 渲染 deck 到输出目录。
|
|
108
|
+
* editable 模式下使用单页编辑器架构:复制 server 的 editor.html/editor.js,
|
|
109
|
+
* 内嵌 renderEditorData 生成的 EditorData,资源路径使用相对路径。
|
|
64
110
|
*/
|
|
65
111
|
export async function renderGoalToDir(goal, options = {}) {
|
|
66
112
|
const { outDir = './output', editable = false, width, height } = options;
|
|
67
113
|
const outputDir = path.resolve(outDir);
|
|
68
114
|
const assetsDir = path.join(outputDir, 'assets');
|
|
69
|
-
const result = renderDeck(goal, { width, height, editable });
|
|
70
115
|
await copyThemeAssets(goal.theme, assetsDir);
|
|
71
|
-
const indexName = editable ? 'editor.html' : 'index.html';
|
|
72
|
-
const indexPath = path.join(outputDir, indexName);
|
|
73
116
|
await mkdir(outputDir, { recursive: true });
|
|
117
|
+
if (editable) {
|
|
118
|
+
const data = renderEditorData(goal, { width: width ?? 1280, height: height ?? 720 });
|
|
119
|
+
const rendererTemplatesDir = resolvePackagePath('@lemonppt/renderer', 'templates');
|
|
120
|
+
let editorHtml = await readFile(path.join(rendererTemplatesDir, 'editor.html'), 'utf-8');
|
|
121
|
+
let editorJs = await readFile(path.join(rendererTemplatesDir, 'editor.js'), 'utf-8');
|
|
122
|
+
// 静态文件模式下使用相对资源路径
|
|
123
|
+
editorHtml = editorHtml.replace(/\/deck\/assets\//g, './assets/');
|
|
124
|
+
editorHtml = editorHtml.replace(/src="\/editor\.js"/g, 'src="./editor.js"');
|
|
125
|
+
// 内嵌 EditorData,让 editor.js 在静态模式下无需请求 API
|
|
126
|
+
const embeddedData = `<script>
|
|
127
|
+
window.__lemonPPT_assetsBase = './assets/';
|
|
128
|
+
window.__lemonPPT_editorData = ${JSON.stringify(data)};
|
|
129
|
+
</script>`;
|
|
130
|
+
editorHtml = editorHtml.replace('</head>', `${embeddedData}\n</head>`);
|
|
131
|
+
const indexPath = path.join(outputDir, 'editor.html');
|
|
132
|
+
await writeFile(indexPath, editorHtml, 'utf-8');
|
|
133
|
+
await writeFile(path.join(outputDir, 'editor.js'), editorJs, 'utf-8');
|
|
134
|
+
const assets = [
|
|
135
|
+
'./assets/fonts/fonts.css',
|
|
136
|
+
`./assets/${data.theme}.css`,
|
|
137
|
+
'./assets/jquery.min.js',
|
|
138
|
+
'./assets/editor-script.js',
|
|
139
|
+
'./assets/client-render.js',
|
|
140
|
+
'./assets/theme-echarts.js',
|
|
141
|
+
];
|
|
142
|
+
return { html: editorHtml, indexPath, assetsDir, assets };
|
|
143
|
+
}
|
|
144
|
+
const result = renderDeck(goal, { width, height });
|
|
145
|
+
const indexPath = path.join(outputDir, 'index.html');
|
|
74
146
|
await writeFile(indexPath, result.html, 'utf-8');
|
|
75
147
|
return { html: result.html, indexPath, assetsDir, assets: result.assets };
|
|
76
148
|
}
|
|
@@ -90,3 +162,390 @@ export async function exportGoalToPdf(goal, options) {
|
|
|
90
162
|
await mkdir(path.dirname(outFile), { recursive: true });
|
|
91
163
|
await exportDeckToPdf(goal, { outFile });
|
|
92
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* 列出所有可用主题。
|
|
167
|
+
*/
|
|
168
|
+
export function listThemes() {
|
|
169
|
+
return themes.map((t) => ({ id: t.id, name: t.displayName || t.id }));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 按主题与角色查询候选版式。
|
|
173
|
+
*/
|
|
174
|
+
export function queryLayouts(options) {
|
|
175
|
+
const { theme, role, keyword, needsMedia, limit = 8, seed = `lemon-${Date.now()}` } = options;
|
|
176
|
+
let layouts = listLayoutsByRoleAndTheme(role, theme);
|
|
177
|
+
if (keyword) {
|
|
178
|
+
const kw = keyword.toLowerCase();
|
|
179
|
+
layouts = layouts.filter((m) => [m.id, m.displayName, m.description || '', ...(m.tags || []), m.contentShape || '']
|
|
180
|
+
.join(' ')
|
|
181
|
+
.toLowerCase()
|
|
182
|
+
.includes(kw));
|
|
183
|
+
}
|
|
184
|
+
if (needsMedia) {
|
|
185
|
+
layouts = layouts.filter((m) => m.needsMedia);
|
|
186
|
+
}
|
|
187
|
+
// 按 seed 做可复现的伪随机排序
|
|
188
|
+
let s = 0;
|
|
189
|
+
const seedStr = `${seed}-${role}-${theme}`;
|
|
190
|
+
for (let i = 0; i < seedStr.length; i++) {
|
|
191
|
+
s = (s * 31 + seedStr.charCodeAt(i)) >>> 0;
|
|
192
|
+
}
|
|
193
|
+
if (s === 0)
|
|
194
|
+
s = 123456789;
|
|
195
|
+
let x = s, y = 362436069, z = 521288629, w = 88675123;
|
|
196
|
+
const random = () => {
|
|
197
|
+
const t = x ^ (x << 11);
|
|
198
|
+
x = y;
|
|
199
|
+
y = z;
|
|
200
|
+
z = w;
|
|
201
|
+
w = (w ^ (w >>> 19) ^ (t ^ (t >>> 8))) >>> 0;
|
|
202
|
+
return w / 0xffffffff;
|
|
203
|
+
};
|
|
204
|
+
const copy = [...layouts];
|
|
205
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
206
|
+
const j = Math.floor(random() * (i + 1));
|
|
207
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
208
|
+
}
|
|
209
|
+
const picked = copy.slice(0, limit);
|
|
210
|
+
return {
|
|
211
|
+
theme,
|
|
212
|
+
role,
|
|
213
|
+
count: picked.length,
|
|
214
|
+
layouts: picked.map((m) => ({
|
|
215
|
+
layout: m.id,
|
|
216
|
+
displayName: m.displayName,
|
|
217
|
+
description: m.description || '',
|
|
218
|
+
needsMedia: m.needsMedia,
|
|
219
|
+
})),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 查看指定版式的字段契约。
|
|
224
|
+
*/
|
|
225
|
+
export function inspectLayout(layoutId) {
|
|
226
|
+
const registered = getLayout(layoutId);
|
|
227
|
+
const schema = getLayoutSchema(layoutId);
|
|
228
|
+
if (!registered || !schema)
|
|
229
|
+
return null;
|
|
230
|
+
const meta = registered.meta;
|
|
231
|
+
function simplifyField(field) {
|
|
232
|
+
const out = {
|
|
233
|
+
key: field.key,
|
|
234
|
+
label: field.label,
|
|
235
|
+
type: field.type,
|
|
236
|
+
};
|
|
237
|
+
if (field.defaultValue !== undefined)
|
|
238
|
+
out.defaultValue = field.defaultValue;
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
layout: meta.id,
|
|
243
|
+
displayName: meta.displayName,
|
|
244
|
+
theme: meta.theme,
|
|
245
|
+
role: meta.role,
|
|
246
|
+
description: meta.description || '',
|
|
247
|
+
needsMedia: meta.needsMedia,
|
|
248
|
+
mediaSlots: meta.mediaSlots || [],
|
|
249
|
+
tags: meta.tags || [],
|
|
250
|
+
contentShape: meta.contentShape || '',
|
|
251
|
+
fields: schema.fields.map(simplifyField),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function createSeededRandom(seed) {
|
|
255
|
+
let s = 0;
|
|
256
|
+
for (let i = 0; i < seed.length; i++) {
|
|
257
|
+
s = (s * 31 + seed.charCodeAt(i)) >>> 0;
|
|
258
|
+
}
|
|
259
|
+
if (s === 0)
|
|
260
|
+
s = 123456789;
|
|
261
|
+
let x = s;
|
|
262
|
+
let y = 362436069;
|
|
263
|
+
let z = 521288629;
|
|
264
|
+
let w = 88675123;
|
|
265
|
+
return () => {
|
|
266
|
+
const t = x ^ (x << 11);
|
|
267
|
+
x = y;
|
|
268
|
+
y = z;
|
|
269
|
+
z = w;
|
|
270
|
+
w = (w ^ (w >>> 19) ^ (t ^ (t >>> 8))) >>> 0;
|
|
271
|
+
return w / 0xffffffff;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function chooseRoles(pageCount, seed) {
|
|
275
|
+
const random = createSeededRandom(seed);
|
|
276
|
+
if (pageCount < 3) {
|
|
277
|
+
return Array.from({ length: pageCount }, () => 'content');
|
|
278
|
+
}
|
|
279
|
+
const roles = ['cover'];
|
|
280
|
+
if (pageCount >= 4)
|
|
281
|
+
roles.push('tableOfContents');
|
|
282
|
+
const contentRoles = ['content', 'metric', 'chart', 'process', 'comparison', 'feature', 'timeline', 'quote', 'image', 'table', 'stats'];
|
|
283
|
+
const remaining = pageCount - roles.length - 1;
|
|
284
|
+
for (let i = 0; i < remaining; i++) {
|
|
285
|
+
const idx = Math.floor(random() * contentRoles.length);
|
|
286
|
+
roles.push(contentRoles[idx]);
|
|
287
|
+
}
|
|
288
|
+
roles.push('closing');
|
|
289
|
+
return roles;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 生成只含 role 的 goal.json 骨架。
|
|
293
|
+
*/
|
|
294
|
+
export async function scaffoldGoalToFile(options) {
|
|
295
|
+
const { title, goal, audience = '内部团队', owner, theme = 'theme01', pages = 8, language = 'zh', seed = `lemon-${Date.now()}`, outFile, } = options;
|
|
296
|
+
const roles = chooseRoles(pages, seed);
|
|
297
|
+
const { composeDeckFromRaw: compose } = await import('@lemonppt/composer');
|
|
298
|
+
const result = compose({
|
|
299
|
+
title,
|
|
300
|
+
goal,
|
|
301
|
+
audience,
|
|
302
|
+
owner,
|
|
303
|
+
theme,
|
|
304
|
+
language,
|
|
305
|
+
pageCount: pages,
|
|
306
|
+
randomSeed: seed,
|
|
307
|
+
slides: roles.map((role) => ({ role: role, props: {} })),
|
|
308
|
+
});
|
|
309
|
+
if (outFile) {
|
|
310
|
+
await mkdir(path.dirname(path.resolve(outFile)), { recursive: true });
|
|
311
|
+
await writeFile(path.resolve(outFile), JSON.stringify(result, null, 2), 'utf-8');
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
function getDefaultValue(field) {
|
|
316
|
+
if (field.defaultValue !== undefined)
|
|
317
|
+
return field.defaultValue;
|
|
318
|
+
switch (field.type) {
|
|
319
|
+
case 'text':
|
|
320
|
+
case 'textarea':
|
|
321
|
+
return '';
|
|
322
|
+
case 'number':
|
|
323
|
+
case 'slider':
|
|
324
|
+
return field.min ?? 0;
|
|
325
|
+
case 'boolean':
|
|
326
|
+
return false;
|
|
327
|
+
case 'array':
|
|
328
|
+
return [];
|
|
329
|
+
case 'select':
|
|
330
|
+
return field.options?.[0]?.value ?? '';
|
|
331
|
+
case 'image':
|
|
332
|
+
case 'color':
|
|
333
|
+
return '';
|
|
334
|
+
case 'object':
|
|
335
|
+
return {};
|
|
336
|
+
default:
|
|
337
|
+
return '';
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function normalizePropsWithSchema(props, fields) {
|
|
341
|
+
const result = {};
|
|
342
|
+
const knownKeys = new Set();
|
|
343
|
+
for (const field of fields || []) {
|
|
344
|
+
const key = field.key;
|
|
345
|
+
knownKeys.add(key);
|
|
346
|
+
const current = props[key];
|
|
347
|
+
if (field.type === 'array' && field.itemSchema) {
|
|
348
|
+
if (!Array.isArray(current)) {
|
|
349
|
+
result[key] = [];
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
result[key] = current.map((item) => item && typeof item === 'object'
|
|
353
|
+
? normalizePropsWithSchema(item, field.itemSchema)
|
|
354
|
+
: item);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else if (current === undefined || current === null) {
|
|
358
|
+
result[key] = getDefaultValue(field);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
result[key] = current;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const key of Object.keys(props)) {
|
|
365
|
+
if (!knownKeys.has(key) && !key.startsWith('_')) {
|
|
366
|
+
result[key] = props[key];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* 规范化 goal.json 的 props。
|
|
373
|
+
*/
|
|
374
|
+
export async function writeSafePropsToFile(options) {
|
|
375
|
+
const { goalPath, write } = options;
|
|
376
|
+
const preprocessed = preprocessAgentGoal(JSON.parse(await readFile(path.resolve(goalPath), 'utf-8')));
|
|
377
|
+
const { composeDeckFromRaw: compose } = await import('@lemonppt/composer');
|
|
378
|
+
const composed = compose({
|
|
379
|
+
title: preprocessed.title,
|
|
380
|
+
goal: preprocessed.goal,
|
|
381
|
+
audience: preprocessed.audience,
|
|
382
|
+
owner: preprocessed.owner,
|
|
383
|
+
theme: preprocessed.theme,
|
|
384
|
+
language: preprocessed.language,
|
|
385
|
+
colorScheme: preprocessed.colorScheme,
|
|
386
|
+
appearance: preprocessed.appearance,
|
|
387
|
+
pageCount: preprocessed.pageCount,
|
|
388
|
+
randomSeed: preprocessed.randomSeed,
|
|
389
|
+
slides: preprocessed.slides.map((s) => ({ role: s.role, layout: s.layout, props: s.props })),
|
|
390
|
+
});
|
|
391
|
+
const layoutChanges = [];
|
|
392
|
+
const unknownFields = [];
|
|
393
|
+
const safeSlides = composed.slides.map((slide, index) => {
|
|
394
|
+
const originalLayout = preprocessed.slides[index]?.layout;
|
|
395
|
+
const registered = getLayout(slide.layout);
|
|
396
|
+
const schema = registered ? getLayoutSchema(slide.layout) : undefined;
|
|
397
|
+
if (originalLayout && originalLayout !== slide.layout) {
|
|
398
|
+
layoutChanges.push({ index: index + 1, from: originalLayout, to: slide.layout });
|
|
399
|
+
}
|
|
400
|
+
if (!schema)
|
|
401
|
+
return { ...slide, props: slide.props };
|
|
402
|
+
const normalized = normalizePropsWithSchema(slide.props || {}, schema.fields);
|
|
403
|
+
const unknownKeys = Object.keys(slide.props || {}).filter((k) => !schema.fields.some((f) => f.key === k) && !k.startsWith('_'));
|
|
404
|
+
if (unknownKeys.length > 0) {
|
|
405
|
+
unknownFields.push({ index: index + 1, layout: slide.layout, keys: unknownKeys });
|
|
406
|
+
}
|
|
407
|
+
return { ...slide, props: normalized };
|
|
408
|
+
});
|
|
409
|
+
const safeGoal = { ...composed, slides: safeSlides };
|
|
410
|
+
const validation = validateDeckGoal(safeGoal);
|
|
411
|
+
if (write) {
|
|
412
|
+
await writeFile(path.resolve(goalPath), JSON.stringify(safeGoal, null, 2), 'utf-8');
|
|
413
|
+
}
|
|
414
|
+
if (!validation.success) {
|
|
415
|
+
throw new Error(`goal.json 校验失败: ${JSON.stringify(validation.errors?.format())}`);
|
|
416
|
+
}
|
|
417
|
+
return { valid: true, layoutChanges, unknownFields, goal: safeGoal };
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 校验 goal.json 规范。
|
|
421
|
+
*/
|
|
422
|
+
export async function validateGoalSpec(goalPath, strict) {
|
|
423
|
+
const raw = preprocessAgentGoal(JSON.parse(await readFile(path.resolve(goalPath), 'utf-8')));
|
|
424
|
+
const result = {
|
|
425
|
+
valid: false,
|
|
426
|
+
errors: [],
|
|
427
|
+
warnings: [],
|
|
428
|
+
};
|
|
429
|
+
const validation = validateDeckGoal(raw);
|
|
430
|
+
if (!validation.success || !validation.data) {
|
|
431
|
+
result.errors.push({ type: 'schema', detail: validation.errors?.format() });
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
const goal = validation.data;
|
|
435
|
+
const countErrors = validateSlideCount({ pageCount: goal.pageCount, slides: goal.slides });
|
|
436
|
+
if (countErrors.length > 0) {
|
|
437
|
+
result.errors.push(...countErrors.map((msg) => ({ type: 'count', detail: msg })));
|
|
438
|
+
}
|
|
439
|
+
const contentWarnings = validateDeckGoalContent(goal);
|
|
440
|
+
if (contentWarnings.length > 0) {
|
|
441
|
+
if (strict) {
|
|
442
|
+
result.errors.push(...contentWarnings.map((msg) => ({ type: 'content', detail: msg })));
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
result.warnings.push(...contentWarnings);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const firstSlide = goal.slides[0];
|
|
449
|
+
const lastSlide = goal.slides[goal.slides.length - 1];
|
|
450
|
+
if (firstSlide?.role !== 'cover')
|
|
451
|
+
result.warnings.push('第一页建议使用 cover 角色');
|
|
452
|
+
if (lastSlide?.role !== 'closing')
|
|
453
|
+
result.warnings.push('最后一页建议使用 closing 角色');
|
|
454
|
+
if (result.errors.length === 0) {
|
|
455
|
+
result.valid = true;
|
|
456
|
+
}
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* 校验渲染后的 HTML deck 结构是否完整。
|
|
461
|
+
*/
|
|
462
|
+
export async function validateDeck(options) {
|
|
463
|
+
const result = { valid: false, slides: 0, errors: [], warnings: [] };
|
|
464
|
+
const indexPath = path.resolve(options.deckDir, 'index.html');
|
|
465
|
+
if (!existsSync(indexPath)) {
|
|
466
|
+
result.errors.push(`deck index.html not found: ${indexPath}`);
|
|
467
|
+
return result;
|
|
468
|
+
}
|
|
469
|
+
const html = await readFile(indexPath, 'utf-8');
|
|
470
|
+
if (html.length < 100) {
|
|
471
|
+
result.errors.push('index.html is too short, render may have failed');
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
// 估算 slide 数量:通过 lp-slide-wrapper 容器
|
|
475
|
+
const wrapperMatches = html.match(/class="[^"]*\blp-slide-wrapper\b[^"]*"/g) ?? [];
|
|
476
|
+
const indexMatches = html.match(/data-slide-index="\d+"/g) ?? [];
|
|
477
|
+
result.slides = Math.max(wrapperMatches.length, indexMatches.length);
|
|
478
|
+
if (result.slides === 0) {
|
|
479
|
+
result.errors.push('no slides found in index.html');
|
|
480
|
+
}
|
|
481
|
+
if (options.goalPath) {
|
|
482
|
+
const goal = JSON.parse(await readFile(path.resolve(options.goalPath), 'utf-8'));
|
|
483
|
+
const expected = goal.slides.length;
|
|
484
|
+
if (result.slides !== expected) {
|
|
485
|
+
result.errors.push(`slide count mismatch: deck=${result.slides}, goal=${expected}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (!html.includes('</html>')) {
|
|
489
|
+
result.warnings.push('index.html may be truncated (missing closing </html>)');
|
|
490
|
+
}
|
|
491
|
+
if (result.errors.length === 0) {
|
|
492
|
+
result.valid = true;
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
function collectTextStrings(value) {
|
|
497
|
+
const results = [];
|
|
498
|
+
function walk(v) {
|
|
499
|
+
if (typeof v === 'string') {
|
|
500
|
+
const trimmed = v.trim();
|
|
501
|
+
if (trimmed.length >= 4)
|
|
502
|
+
results.push(trimmed);
|
|
503
|
+
}
|
|
504
|
+
else if (Array.isArray(v)) {
|
|
505
|
+
v.forEach(walk);
|
|
506
|
+
}
|
|
507
|
+
else if (v && typeof v === 'object') {
|
|
508
|
+
Object.values(v).forEach(walk);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
walk(value);
|
|
512
|
+
return [...new Set(results)];
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* 校验 goal.json 中的文案是否都出现在渲染后的 HTML 中。
|
|
516
|
+
*/
|
|
517
|
+
export async function validateGoalCopy(options) {
|
|
518
|
+
const result = { valid: false, missing: [], checked: 0, errors: [] };
|
|
519
|
+
const indexPath = path.resolve(options.deckDir, 'index.html');
|
|
520
|
+
if (!existsSync(indexPath)) {
|
|
521
|
+
result.errors.push(`deck index.html not found: ${indexPath}`);
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
const goal = JSON.parse(await readFile(path.resolve(options.goalPath), 'utf-8'));
|
|
525
|
+
const html = await readFile(indexPath, 'utf-8');
|
|
526
|
+
const texts = collectTextStrings(goal.slides.map((s) => s.props));
|
|
527
|
+
result.checked = texts.length;
|
|
528
|
+
const normalizedHtml = html
|
|
529
|
+
.replace(/&/g, '&')
|
|
530
|
+
.replace(/"/g, '"')
|
|
531
|
+
.replace(/'/g, "'")
|
|
532
|
+
.replace(/</g, '<')
|
|
533
|
+
.replace(/>/g, '>')
|
|
534
|
+
.replace(/\s+/g, ' ');
|
|
535
|
+
for (const text of texts) {
|
|
536
|
+
const normalizedText = text.replace(/\s+/g, ' ');
|
|
537
|
+
if (!normalizedHtml.includes(normalizedText)) {
|
|
538
|
+
result.missing.push(text.slice(0, 80));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (result.missing.length > 0) {
|
|
542
|
+
result.errors.push(`${result.missing.length} / ${result.checked} text snippets missing in rendered HTML`);
|
|
543
|
+
}
|
|
544
|
+
else if (result.checked === 0) {
|
|
545
|
+
result.errors.push('no text found in goal.json to validate');
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
result.valid = true;
|
|
549
|
+
}
|
|
550
|
+
return result;
|
|
551
|
+
}
|
package/dist/install-skill.d.ts
CHANGED
package/dist/install-skill.js
CHANGED
|
@@ -1,51 +1,100 @@
|
|
|
1
1
|
// lemonPPT - AI-powered presentation generation
|
|
2
2
|
// Copyright (c) 2026 lemonforme
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
-
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { copyFile, cp, mkdir, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
const DEFAULT_AGENTS = ['claude', 'codex', 'cursor'];
|
|
10
10
|
function resolveSkillMdPath() {
|
|
11
|
-
// Published package: SKILL.md lives next to the package root (one level above dist/)
|
|
12
11
|
const mainUrl = import.meta.resolve('@lemonppt/cli');
|
|
13
12
|
const pkgRoot = path.resolve(path.dirname(fileURLToPath(mainUrl)), '..');
|
|
13
|
+
// Local monorepo dev: repo root is two levels above the package root
|
|
14
|
+
const local = path.resolve(pkgRoot, '..', '..', 'SKILL.md');
|
|
15
|
+
if (existsSync(local) && existsSync(path.resolve(pkgRoot, '..', '..', 'apps', 'server'))) {
|
|
16
|
+
return local;
|
|
17
|
+
}
|
|
18
|
+
// Published package: SKILL.md lives next to the package root (one level above dist/)
|
|
14
19
|
const published = path.join(pkgRoot, 'SKILL.md');
|
|
15
20
|
if (existsSync(published)) {
|
|
16
21
|
return published;
|
|
17
22
|
}
|
|
18
|
-
// Local monorepo dev: fallback to repo root SKILL.md
|
|
19
|
-
const local = path.resolve(pkgRoot, '..', '..', 'SKILL.md');
|
|
20
|
-
if (existsSync(local)) {
|
|
21
|
-
return local;
|
|
22
|
-
}
|
|
23
23
|
throw new Error('SKILL.md not found. It should be bundled with @lemonppt/cli or exist at the repo root.');
|
|
24
24
|
}
|
|
25
|
+
function resolveRepoRoot(skillMdPath) {
|
|
26
|
+
return path.dirname(skillMdPath);
|
|
27
|
+
}
|
|
28
|
+
function resolveSkillBundlePath(skillMdPath) {
|
|
29
|
+
const repoRoot = resolveRepoRoot(skillMdPath);
|
|
30
|
+
const bundlePath = path.join(repoRoot, 'skills', 'lemonppt');
|
|
31
|
+
if (existsSync(bundlePath)) {
|
|
32
|
+
return bundlePath;
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Skill bundle not found at skills/lemonppt. Did you run the build script?');
|
|
35
|
+
}
|
|
36
|
+
function isSourceRepo(repoRoot) {
|
|
37
|
+
return existsSync(path.join(repoRoot, 'apps', 'server'));
|
|
38
|
+
}
|
|
39
|
+
function resolveCliWrapper(repoRoot) {
|
|
40
|
+
if (isSourceRepo(repoRoot)) {
|
|
41
|
+
const cliPath = path.join(repoRoot, 'packages', 'cli', 'dist', 'cli.js');
|
|
42
|
+
return {
|
|
43
|
+
type: 'source',
|
|
44
|
+
cli: cliPath,
|
|
45
|
+
sh: `#!/bin/bash\nset -e\nexec "${cliPath}" "$@"\n`,
|
|
46
|
+
ps1: `#Requires -Version 5.1\n& node "${cliPath}" @args\n`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
type: 'published',
|
|
51
|
+
sh: `#!/bin/bash\nset -e\nexec npx @lemonppt/cli "$@"\n`,
|
|
52
|
+
ps1: `#Requires -Version 5.1\n& npx @lemonppt/cli @args\n`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function resolveCliMarker(wrapper) {
|
|
56
|
+
return JSON.stringify(wrapper.type === 'source' && wrapper.cli
|
|
57
|
+
? { type: wrapper.type, cli: wrapper.cli }
|
|
58
|
+
: { type: wrapper.type }, null, 2);
|
|
59
|
+
}
|
|
25
60
|
async function installAgent(agent) {
|
|
26
61
|
const homeDir = os.homedir();
|
|
27
62
|
const skillDir = path.join(homeDir, `.${agent}/skills/lemonppt`);
|
|
28
63
|
await mkdir(skillDir, { recursive: true });
|
|
29
64
|
const skillMdSource = resolveSkillMdPath();
|
|
30
|
-
|
|
31
|
-
await
|
|
65
|
+
const bundlePath = resolveSkillBundlePath(skillMdSource);
|
|
66
|
+
await cp(bundlePath, skillDir, { recursive: true, force: true });
|
|
67
|
+
// 兼容 OpenAI/Codex 等同时读取根目录 openai.yaml 的框架
|
|
68
|
+
const agentYamlSource = path.join(bundlePath, 'agents', 'openai.yaml');
|
|
69
|
+
if (existsSync(agentYamlSource)) {
|
|
70
|
+
await copyFile(agentYamlSource, path.join(skillDir, 'openai.yaml'));
|
|
71
|
+
}
|
|
72
|
+
const repoRoot = resolveRepoRoot(skillMdSource);
|
|
73
|
+
const wrapper = resolveCliWrapper(repoRoot);
|
|
32
74
|
const scriptsDir = path.join(skillDir, 'scripts');
|
|
33
|
-
await
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
`;
|
|
38
|
-
await writeFile(path.join(scriptsDir, 'lemonppt.sh'), wrapperSh, { mode: 0o755 });
|
|
39
|
-
const wrapperPs1 = `#Requires -Version 5.1
|
|
40
|
-
& npx @lemonppt/cli @args
|
|
41
|
-
`;
|
|
42
|
-
await writeFile(path.join(scriptsDir, 'lemonppt.ps1'), wrapperPs1, { mode: 0o755 });
|
|
43
|
-
console.log(`✓ Installed lemonPPT skill for ${agent} at ${skillDir}`);
|
|
75
|
+
await writeFile(path.join(scriptsDir, 'lemonppt.sh'), wrapper.sh, { mode: 0o755 });
|
|
76
|
+
await writeFile(path.join(scriptsDir, 'lemonppt.ps1'), wrapper.ps1, { mode: 0o755 });
|
|
77
|
+
await writeFile(path.join(scriptsDir, '.cli-path.json'), resolveCliMarker(wrapper));
|
|
78
|
+
console.log(`✓ Installed lemonPPT skill for ${agent} at ${skillDir} (${wrapper.type} mode)`);
|
|
44
79
|
}
|
|
45
80
|
/**
|
|
46
81
|
* 将 lemonPPT skill 安装到常见 AI Agent 的技能目录。
|
|
47
82
|
*/
|
|
48
83
|
export async function installSkill(options = {}) {
|
|
84
|
+
if (options.target) {
|
|
85
|
+
const skillMdSource = resolveSkillMdPath();
|
|
86
|
+
const bundlePath = resolveSkillBundlePath(skillMdSource);
|
|
87
|
+
await mkdir(options.target, { recursive: true });
|
|
88
|
+
await cp(bundlePath, options.target, { recursive: true, force: true });
|
|
89
|
+
const repoRoot = resolveRepoRoot(skillMdSource);
|
|
90
|
+
const wrapper = resolveCliWrapper(repoRoot);
|
|
91
|
+
const scriptsDir = path.join(options.target, 'scripts');
|
|
92
|
+
await writeFile(path.join(scriptsDir, 'lemonppt.sh'), wrapper.sh, { mode: 0o755 });
|
|
93
|
+
await writeFile(path.join(scriptsDir, 'lemonppt.ps1'), wrapper.ps1, { mode: 0o755 });
|
|
94
|
+
await writeFile(path.join(scriptsDir, '.cli-path.json'), resolveCliMarker(wrapper));
|
|
95
|
+
console.log(`✓ Copied skill bundle to ${options.target} (${wrapper.type} mode)`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
49
98
|
const agents = options.agents?.length ? options.agents : DEFAULT_AGENTS;
|
|
50
99
|
for (const agent of agents) {
|
|
51
100
|
await installAgent(agent);
|