@lemonppt/cli 0.1.8 → 0.2.0

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/dist/index.js CHANGED
@@ -2,15 +2,16 @@
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';
5
+ import { normalizeDeckGoal, validateDeckGoal, validateDeckGoalContent, validateSlideCount } from '@lemonppt/core';
6
6
  import { exportDeckToPdf, exportDeckToPptx, renderDeck, } from '@lemonppt/renderer';
7
- import { getTheme } from '@lemonppt/themes';
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';
9
10
  import path from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  function resolveTheme(themeId) {
12
- const id = themeId || 'base';
13
- return getTheme(id) ? id : 'base';
13
+ const id = themeId || 'theme01';
14
+ return getTheme(id) ? id : 'theme01';
14
15
  }
15
16
  async function copyThemeAssets(themeId, assetsDir) {
16
17
  const theme = resolveTheme(themeId);
@@ -22,17 +23,34 @@ async function copyThemeAssets(themeId, assetsDir) {
22
23
  const fontsSource = resolvePackagePath('@lemonppt/renderer', 'assets', 'fonts');
23
24
  const fontsDest = path.join(assetsDir, 'fonts');
24
25
  await cp(fontsSource, fontsDest, { recursive: true, force: true });
26
+ // 复制客户端离线渲染脚本,支持静态文件模式下结构编辑
27
+ const clientRenderSource = resolvePackagePath('@lemonppt/renderer', 'dist', 'client-render.js');
28
+ const clientRenderDest = path.join(assetsDir, 'client-render.js');
29
+ await copyFile(clientRenderSource, clientRenderDest);
30
+ // 复制 jQuery,供编辑器初始化自定义滚动条样式类
31
+ const jquerySource = resolvePackagePath('jquery', 'dist', 'jquery.min.js');
32
+ const jqueryDest = path.join(assetsDir, 'jquery.min.js');
33
+ await copyFile(jquerySource, jqueryDest);
25
34
  }
26
35
  function resolvePackagePath(pkg, ...segments) {
27
- const mainUrl = import.meta.resolve(pkg);
28
- const pkgRoot = path.resolve(path.dirname(fileURLToPath(mainUrl)), '..');
29
- return path.join(pkgRoot, ...segments);
36
+ // 优先通过 package.json 定位包根(第三方 npm 包)
37
+ try {
38
+ const pkgJsonUrl = import.meta.resolve(`${pkg}/package.json`);
39
+ const pkgRoot = path.dirname(fileURLToPath(pkgJsonUrl));
40
+ return path.join(pkgRoot, ...segments);
41
+ }
42
+ catch {
43
+ // 工作区包可能未导出 package.json,回退到主入口的父目录的父目录
44
+ const mainUrl = import.meta.resolve(pkg);
45
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(mainUrl)), '..');
46
+ return path.join(pkgRoot, ...segments);
47
+ }
30
48
  }
31
49
  /**
32
50
  * 生成 goal.json 并可选写入文件。
33
51
  */
34
52
  export async function generateGoalToFile(options) {
35
- const { input, pageCount = 8, theme = 'base', language = 'zh', apiKey, baseUrl, model, outFile, } = options;
53
+ const { input, pageCount = 8, theme = 'theme01', language = 'zh', apiKey, baseUrl, model, outFile, } = options;
36
54
  const result = await generateGoal({
37
55
  input,
38
56
  pageCount,
@@ -90,3 +108,297 @@ export async function exportGoalToPdf(goal, options) {
90
108
  await mkdir(path.dirname(outFile), { recursive: true });
91
109
  await exportDeckToPdf(goal, { outFile });
92
110
  }
111
+ /**
112
+ * 列出所有可用主题。
113
+ */
114
+ export function listThemes() {
115
+ return themes.map((t) => ({ id: t.id, name: t.displayName || t.id }));
116
+ }
117
+ /**
118
+ * 按主题与角色查询候选版式。
119
+ */
120
+ export function queryLayouts(options) {
121
+ const { theme, role, keyword, needsMedia, limit = 8, seed = `lemon-${Date.now()}` } = options;
122
+ let layouts = listLayoutsByRoleAndTheme(role, theme);
123
+ if (keyword) {
124
+ const kw = keyword.toLowerCase();
125
+ layouts = layouts.filter((m) => [m.id, m.displayName, m.description || '', ...(m.tags || []), m.contentShape || '']
126
+ .join(' ')
127
+ .toLowerCase()
128
+ .includes(kw));
129
+ }
130
+ if (needsMedia) {
131
+ layouts = layouts.filter((m) => m.needsMedia);
132
+ }
133
+ // 按 seed 做可复现的伪随机排序
134
+ let s = 0;
135
+ const seedStr = `${seed}-${role}-${theme}`;
136
+ for (let i = 0; i < seedStr.length; i++) {
137
+ s = (s * 31 + seedStr.charCodeAt(i)) >>> 0;
138
+ }
139
+ if (s === 0)
140
+ s = 123456789;
141
+ let x = s, y = 362436069, z = 521288629, w = 88675123;
142
+ const random = () => {
143
+ const t = x ^ (x << 11);
144
+ x = y;
145
+ y = z;
146
+ z = w;
147
+ w = (w ^ (w >>> 19) ^ (t ^ (t >>> 8))) >>> 0;
148
+ return w / 0xffffffff;
149
+ };
150
+ const copy = [...layouts];
151
+ for (let i = copy.length - 1; i > 0; i--) {
152
+ const j = Math.floor(random() * (i + 1));
153
+ [copy[i], copy[j]] = [copy[j], copy[i]];
154
+ }
155
+ const picked = copy.slice(0, limit);
156
+ return {
157
+ theme,
158
+ role,
159
+ count: picked.length,
160
+ layouts: picked.map((m) => ({
161
+ layout: m.id,
162
+ displayName: m.displayName,
163
+ description: m.description || '',
164
+ needsMedia: m.needsMedia,
165
+ })),
166
+ };
167
+ }
168
+ /**
169
+ * 查看指定版式的字段契约。
170
+ */
171
+ export function inspectLayout(layoutId) {
172
+ const registered = getLayout(layoutId);
173
+ const schema = getLayoutSchema(layoutId);
174
+ if (!registered || !schema)
175
+ return null;
176
+ const meta = registered.meta;
177
+ function simplifyField(field) {
178
+ const out = {
179
+ key: field.key,
180
+ label: field.label,
181
+ type: field.type,
182
+ };
183
+ if (field.defaultValue !== undefined)
184
+ out.defaultValue = field.defaultValue;
185
+ return out;
186
+ }
187
+ return {
188
+ layout: meta.id,
189
+ displayName: meta.displayName,
190
+ theme: meta.theme,
191
+ role: meta.role,
192
+ description: meta.description || '',
193
+ needsMedia: meta.needsMedia,
194
+ mediaSlots: meta.mediaSlots || [],
195
+ tags: meta.tags || [],
196
+ contentShape: meta.contentShape || '',
197
+ fields: schema.fields.map(simplifyField),
198
+ };
199
+ }
200
+ function createSeededRandom(seed) {
201
+ let s = 0;
202
+ for (let i = 0; i < seed.length; i++) {
203
+ s = (s * 31 + seed.charCodeAt(i)) >>> 0;
204
+ }
205
+ if (s === 0)
206
+ s = 123456789;
207
+ let x = s;
208
+ let y = 362436069;
209
+ let z = 521288629;
210
+ let w = 88675123;
211
+ return () => {
212
+ const t = x ^ (x << 11);
213
+ x = y;
214
+ y = z;
215
+ z = w;
216
+ w = (w ^ (w >>> 19) ^ (t ^ (t >>> 8))) >>> 0;
217
+ return w / 0xffffffff;
218
+ };
219
+ }
220
+ function chooseRoles(pageCount, seed) {
221
+ const random = createSeededRandom(seed);
222
+ if (pageCount < 3) {
223
+ return Array.from({ length: pageCount }, () => 'content');
224
+ }
225
+ const roles = ['cover'];
226
+ if (pageCount >= 4)
227
+ roles.push('tableOfContents');
228
+ const contentRoles = ['content', 'metric', 'chart', 'process', 'comparison', 'feature', 'timeline', 'quote', 'image', 'table', 'stats'];
229
+ const remaining = pageCount - roles.length - 1;
230
+ for (let i = 0; i < remaining; i++) {
231
+ const idx = Math.floor(random() * contentRoles.length);
232
+ roles.push(contentRoles[idx]);
233
+ }
234
+ roles.push('closing');
235
+ return roles;
236
+ }
237
+ /**
238
+ * 生成只含 role 的 goal.json 骨架。
239
+ */
240
+ export async function scaffoldGoalToFile(options) {
241
+ const { title, goal, audience = '内部团队', owner, theme = 'theme01', pages = 8, language = 'zh', seed = `lemon-${Date.now()}`, outFile, } = options;
242
+ const roles = chooseRoles(pages, seed);
243
+ const { composeDeckFromRaw: compose } = await import('@lemonppt/composer');
244
+ const result = compose({
245
+ title,
246
+ goal,
247
+ audience,
248
+ owner,
249
+ theme,
250
+ language,
251
+ pageCount: pages,
252
+ randomSeed: seed,
253
+ slides: roles.map((role) => ({ role: role, props: {} })),
254
+ });
255
+ if (outFile) {
256
+ await mkdir(path.dirname(path.resolve(outFile)), { recursive: true });
257
+ await writeFile(path.resolve(outFile), JSON.stringify(result, null, 2), 'utf-8');
258
+ }
259
+ return result;
260
+ }
261
+ function getDefaultValue(field) {
262
+ if (field.defaultValue !== undefined)
263
+ return field.defaultValue;
264
+ switch (field.type) {
265
+ case 'text':
266
+ case 'textarea':
267
+ return '';
268
+ case 'number':
269
+ case 'slider':
270
+ return field.min ?? 0;
271
+ case 'boolean':
272
+ return false;
273
+ case 'array':
274
+ return [];
275
+ case 'select':
276
+ return field.options?.[0]?.value ?? '';
277
+ case 'image':
278
+ case 'color':
279
+ return '';
280
+ case 'object':
281
+ return {};
282
+ default:
283
+ return '';
284
+ }
285
+ }
286
+ function normalizePropsWithSchema(props, fields) {
287
+ const result = {};
288
+ const knownKeys = new Set();
289
+ for (const field of fields || []) {
290
+ const key = field.key;
291
+ knownKeys.add(key);
292
+ const current = props[key];
293
+ if (field.type === 'array' && field.itemSchema) {
294
+ if (!Array.isArray(current)) {
295
+ result[key] = [];
296
+ }
297
+ else {
298
+ result[key] = current.map((item) => item && typeof item === 'object'
299
+ ? normalizePropsWithSchema(item, field.itemSchema)
300
+ : item);
301
+ }
302
+ }
303
+ else if (current === undefined || current === null) {
304
+ result[key] = getDefaultValue(field);
305
+ }
306
+ else {
307
+ result[key] = current;
308
+ }
309
+ }
310
+ for (const key of Object.keys(props)) {
311
+ if (!knownKeys.has(key) && !key.startsWith('_')) {
312
+ result[key] = props[key];
313
+ }
314
+ }
315
+ return result;
316
+ }
317
+ /**
318
+ * 规范化 goal.json 的 props。
319
+ */
320
+ export async function writeSafePropsToFile(options) {
321
+ const { goalPath, write } = options;
322
+ const raw = JSON.parse(await readFile(path.resolve(goalPath), 'utf-8'));
323
+ const { composeDeckFromRaw: compose } = await import('@lemonppt/composer');
324
+ const composed = compose({
325
+ title: raw.title,
326
+ goal: raw.goal,
327
+ audience: raw.audience,
328
+ owner: raw.owner,
329
+ theme: raw.theme,
330
+ language: raw.language,
331
+ colorScheme: raw.colorScheme,
332
+ appearance: raw.appearance,
333
+ pageCount: raw.pageCount,
334
+ randomSeed: raw.randomSeed,
335
+ slides: raw.slides.map((s) => ({ role: s.role, layout: s.layout, props: s.props })),
336
+ });
337
+ const layoutChanges = [];
338
+ const unknownFields = [];
339
+ const safeSlides = composed.slides.map((slide, index) => {
340
+ const originalLayout = raw.slides[index]?.layout;
341
+ const registered = getLayout(slide.layout);
342
+ const schema = registered ? getLayoutSchema(slide.layout) : undefined;
343
+ if (originalLayout && originalLayout !== slide.layout) {
344
+ layoutChanges.push({ index: index + 1, from: originalLayout, to: slide.layout });
345
+ }
346
+ if (!schema)
347
+ return { ...slide, props: slide.props };
348
+ const normalized = normalizePropsWithSchema(slide.props || {}, schema.fields);
349
+ const unknownKeys = Object.keys(slide.props || {}).filter((k) => !schema.fields.some((f) => f.key === k) && !k.startsWith('_'));
350
+ if (unknownKeys.length > 0) {
351
+ unknownFields.push({ index: index + 1, layout: slide.layout, keys: unknownKeys });
352
+ }
353
+ return { ...slide, props: normalized };
354
+ });
355
+ const safeGoal = { ...composed, slides: safeSlides };
356
+ const validation = validateDeckGoal(safeGoal);
357
+ if (write) {
358
+ await writeFile(path.resolve(goalPath), JSON.stringify(safeGoal, null, 2), 'utf-8');
359
+ }
360
+ if (!validation.success) {
361
+ throw new Error(`goal.json 校验失败: ${JSON.stringify(validation.errors?.format())}`);
362
+ }
363
+ return { valid: true, layoutChanges, unknownFields, goal: safeGoal };
364
+ }
365
+ /**
366
+ * 校验 goal.json 规范。
367
+ */
368
+ export async function validateGoalSpec(goalPath, strict) {
369
+ const raw = JSON.parse(await readFile(path.resolve(goalPath), 'utf-8'));
370
+ const result = {
371
+ valid: false,
372
+ errors: [],
373
+ warnings: [],
374
+ };
375
+ const validation = validateDeckGoal(raw);
376
+ if (!validation.success || !validation.data) {
377
+ result.errors.push({ type: 'schema', detail: validation.errors?.format() });
378
+ return result;
379
+ }
380
+ const goal = validation.data;
381
+ const countErrors = validateSlideCount({ pageCount: goal.pageCount, slides: goal.slides });
382
+ if (countErrors.length > 0) {
383
+ result.errors.push(...countErrors.map((msg) => ({ type: 'count', detail: msg })));
384
+ }
385
+ const contentWarnings = validateDeckGoalContent(goal);
386
+ if (contentWarnings.length > 0) {
387
+ if (strict) {
388
+ result.errors.push(...contentWarnings.map((msg) => ({ type: 'content', detail: msg })));
389
+ }
390
+ else {
391
+ result.warnings.push(...contentWarnings);
392
+ }
393
+ }
394
+ const firstSlide = goal.slides[0];
395
+ const lastSlide = goal.slides[goal.slides.length - 1];
396
+ if (firstSlide?.role !== 'cover')
397
+ result.warnings.push('第一页建议使用 cover 角色');
398
+ if (lastSlide?.role !== 'closing')
399
+ result.warnings.push('最后一页建议使用 closing 角色');
400
+ if (result.errors.length === 0) {
401
+ result.valid = true;
402
+ }
403
+ return result;
404
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemonppt/cli",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -15,7 +15,8 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "SKILL.md"
18
+ "SKILL.md",
19
+ "agents"
19
20
  ],
20
21
  "publishConfig": {
21
22
  "access": "public"
@@ -30,10 +31,10 @@
30
31
  },
31
32
  "homepage": "https://github.com/lemonforme/lemonPPT#readme",
32
33
  "dependencies": {
33
- "@lemonppt/agent-prompts": "0.1.7",
34
- "@lemonppt/core": "0.1.7",
35
- "@lemonppt/themes": "0.1.7",
36
- "@lemonppt/renderer": "0.1.8"
34
+ "@lemonppt/agent-prompts": "0.2.0",
35
+ "@lemonppt/renderer": "0.2.0",
36
+ "@lemonppt/core": "0.2.0",
37
+ "@lemonppt/themes": "0.2.0"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/node": "^20.0.0",