supaslidev 0.3.6 → 0.4.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/app/components/PresentationCard.vue +67 -0
- package/app/components/PresentationListItem.vue +60 -0
- package/app/composables/useServers.ts +13 -0
- package/app/pages/index.vue +79 -1
- package/dist/cli/index.js +1585 -997
- package/dist/index.d.ts +1 -0
- package/package.json +13 -12
- package/server/api/thumbnail/[id].post.ts +115 -0
- package/server/routes/thumbnails/[...path].get.ts +27 -0
- package/server/utils/process-manager.ts +1 -1
- package/src/cli/commands/deploy.ts +87 -8
- package/src/cli/commands/thumbnail.ts +89 -0
- package/src/cli/index.ts +10 -0
- package/src/shared/optimize-thumbnail.ts +23 -0
- package/src/shared/presentations.ts +20 -1
- package/src/shared/types.ts +1 -0
package/dist/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "supaslidev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "CLI toolkit for managing Supaslidev presentations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slidev",
|
|
@@ -47,24 +47,25 @@
|
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@nuxt/ui": "^4.
|
|
51
|
-
"commander": "^14.0.
|
|
50
|
+
"@nuxt/ui": "^4.6.1",
|
|
51
|
+
"commander": "^14.0.3",
|
|
52
52
|
"js-yaml": "^4.1.1",
|
|
53
53
|
"nuxt": "^4.4.2",
|
|
54
|
-
"
|
|
55
|
-
"
|
|
54
|
+
"sharp": "^0.34.5",
|
|
55
|
+
"tailwindcss": "^4.2.4",
|
|
56
|
+
"vue": "^3.5.33"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
59
|
"@nuxt/kit": "^4.4.2",
|
|
59
60
|
"@nuxt/schema": "^4.4.2",
|
|
60
61
|
"@types/js-yaml": "^4.0.9",
|
|
61
|
-
"@types/node": "^24.
|
|
62
|
-
"tsdown": "^0.21.
|
|
63
|
-
"tsx": "^4.
|
|
64
|
-
"typescript": "^6.0.
|
|
65
|
-
"vitest": "^4.
|
|
66
|
-
"vue-tsc": "^3.
|
|
67
|
-
"create-supaslidev": "^0.
|
|
62
|
+
"@types/node": "^24.12.2",
|
|
63
|
+
"tsdown": "^0.21.9",
|
|
64
|
+
"tsx": "^4.21.0",
|
|
65
|
+
"typescript": "^6.0.3",
|
|
66
|
+
"vitest": "^4.1.5",
|
|
67
|
+
"vue-tsc": "^3.2.7",
|
|
68
|
+
"create-supaslidev": "^0.4.1"
|
|
68
69
|
},
|
|
69
70
|
"scripts": {
|
|
70
71
|
"dev": "nuxt dev",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
|
|
4
|
+
import { isValidPresentationId } from '../../../src/shared/validation.js';
|
|
5
|
+
import { optimizeThumbnail } from '../../../src/shared/optimize-thumbnail.js';
|
|
6
|
+
import { getProjectRoot } from '../../utils/config';
|
|
7
|
+
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
const presentationId = getRouterParam(event, 'id')!;
|
|
10
|
+
const projectRoot = getProjectRoot();
|
|
11
|
+
|
|
12
|
+
if (!isValidPresentationId(presentationId)) {
|
|
13
|
+
throw createError({
|
|
14
|
+
statusCode: 400,
|
|
15
|
+
data: { success: false, error: 'Invalid presentation id' },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const presentationPath = join(projectRoot, 'presentations', presentationId);
|
|
20
|
+
const thumbnailsDir = join(projectRoot, 'thumbnails');
|
|
21
|
+
const outputBase = join(thumbnailsDir, presentationId);
|
|
22
|
+
|
|
23
|
+
if (!existsSync(presentationPath)) {
|
|
24
|
+
throw createError({
|
|
25
|
+
statusCode: 404,
|
|
26
|
+
data: { success: false, error: 'Presentation not found' },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!existsSync(thumbnailsDir)) {
|
|
31
|
+
mkdirSync(thumbnailsDir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Step 1: Export PNG with Slidev
|
|
35
|
+
const exportResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
|
36
|
+
const slidevBin = join(presentationPath, 'node_modules', '.bin', 'slidev');
|
|
37
|
+
const child = spawn(
|
|
38
|
+
slidevBin,
|
|
39
|
+
['export', '--format', 'png', '--range', '1', '--output', outputBase],
|
|
40
|
+
{
|
|
41
|
+
cwd: presentationPath,
|
|
42
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
let stderr = '';
|
|
47
|
+
|
|
48
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
49
|
+
const line = data.toString().trim();
|
|
50
|
+
if (line) console.log(`[thumbnail] ${line}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
child.stderr?.on('data', (data: Buffer) => {
|
|
54
|
+
const line = data.toString().trim();
|
|
55
|
+
stderr += data.toString();
|
|
56
|
+
if (
|
|
57
|
+
line &&
|
|
58
|
+
!line.includes('outside of Vite serving allow list') &&
|
|
59
|
+
!line.includes('Refer to docs https://vite.dev')
|
|
60
|
+
) {
|
|
61
|
+
console.error(`[thumbnail] ${line}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
child.on('error', (err: Error) => {
|
|
66
|
+
resolve({ success: false, error: `Thumbnail generation failed: ${err.message}` });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
child.on('close', (code: number | null) => {
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
const pngDirect = `${outputBase}.png`;
|
|
72
|
+
const pngDir = outputBase;
|
|
73
|
+
const targetFile = join(thumbnailsDir, `${presentationId}.png`);
|
|
74
|
+
|
|
75
|
+
if (!existsSync(pngDirect) && existsSync(pngDir)) {
|
|
76
|
+
const pngs = readdirSync(pngDir)
|
|
77
|
+
.filter((f) => f.endsWith('.png'))
|
|
78
|
+
.sort();
|
|
79
|
+
if (pngs.length > 0) {
|
|
80
|
+
renameSync(join(pngDir, pngs[0]), targetFile);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
resolve({ success: existsSync(targetFile) });
|
|
85
|
+
} else {
|
|
86
|
+
resolve({
|
|
87
|
+
success: false,
|
|
88
|
+
error: `Thumbnail generation failed with exit code ${code}. ${stderr}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!exportResult.success) {
|
|
95
|
+
return exportResult;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Step 2: Optimize PNG to WebP
|
|
99
|
+
const pngFile = join(thumbnailsDir, `${presentationId}.png`);
|
|
100
|
+
try {
|
|
101
|
+
await optimizeThumbnail(pngFile);
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
thumbnailPath: `/thumbnails/${presentationId}.webp`,
|
|
105
|
+
filename: `${presentationId}.webp`,
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
// Fall back to PNG if optimization fails
|
|
109
|
+
return {
|
|
110
|
+
success: true,
|
|
111
|
+
thumbnailPath: `/thumbnails/${presentationId}.png`,
|
|
112
|
+
filename: `${presentationId}.png`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join, basename, relative } from 'node:path';
|
|
2
|
+
import { existsSync, createReadStream } from 'node:fs';
|
|
3
|
+
import { getProjectRoot } from '../../utils/config';
|
|
4
|
+
|
|
5
|
+
export default defineEventHandler((event) => {
|
|
6
|
+
const path = getRouterParam(event, 'path') || '';
|
|
7
|
+
const projectRoot = getProjectRoot();
|
|
8
|
+
const thumbnailsDir = join(projectRoot, 'thumbnails');
|
|
9
|
+
const filePath = join(thumbnailsDir, path);
|
|
10
|
+
|
|
11
|
+
const rel = relative(thumbnailsDir, filePath);
|
|
12
|
+
const isAllowedExt = filePath.endsWith('.png') || filePath.endsWith('.webp');
|
|
13
|
+
if (
|
|
14
|
+
rel.startsWith('..') ||
|
|
15
|
+
!filePath.startsWith(thumbnailsDir) ||
|
|
16
|
+
!existsSync(filePath) ||
|
|
17
|
+
!isAllowedExt
|
|
18
|
+
) {
|
|
19
|
+
throw createError({ statusCode: 404, message: 'Not found' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const contentType = filePath.endsWith('.webp') ? 'image/webp' : 'image/png';
|
|
23
|
+
setHeader(event, 'Content-Type', contentType);
|
|
24
|
+
setHeader(event, 'Content-Disposition', `inline; filename="${basename(filePath)}"`);
|
|
25
|
+
|
|
26
|
+
return sendStream(event, createReadStream(filePath));
|
|
27
|
+
});
|
|
@@ -46,7 +46,7 @@ export function startPresentationServer(
|
|
|
46
46
|
|
|
47
47
|
const port = getNextPort();
|
|
48
48
|
|
|
49
|
-
const child = spawn(slidevBin, ['--port', String(port), '--open', 'false'], {
|
|
49
|
+
const child = spawn(slidevBin, ['--port', String(port), '--remote', '--open', 'false'], {
|
|
50
50
|
cwd: presentationPath,
|
|
51
51
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
52
|
detached: !IS_WINDOWS,
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { join, dirname, resolve } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
cpSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
} from 'node:fs';
|
|
4
13
|
import { fileURLToPath } from 'node:url';
|
|
5
14
|
import { findProjectRoot, getPresentations } from '../utils.js';
|
|
6
15
|
import { regeneratePresentationsJson } from '../../shared/presentations.js';
|
|
16
|
+
import { optimizeThumbnail } from '../../shared/optimize-thumbnail.js';
|
|
7
17
|
|
|
8
18
|
export interface DeployOptions {
|
|
9
19
|
output?: string;
|
|
@@ -160,8 +170,11 @@ export async function deploy(options: DeployOptions = {}): Promise<void> {
|
|
|
160
170
|
console.log(` Presentations: ${presentations.join(', ')}`);
|
|
161
171
|
console.log('');
|
|
162
172
|
|
|
173
|
+
const totalSteps = 5;
|
|
174
|
+
const thumbnailsDir = join(projectRoot, 'thumbnails');
|
|
175
|
+
|
|
163
176
|
// Step 1: Build each presentation with slidev build --base
|
|
164
|
-
console.log(`Step 1/${
|
|
177
|
+
console.log(`Step 1/${totalSteps}: Building ${presentations.length} presentation(s)...\n`);
|
|
165
178
|
|
|
166
179
|
for (const id of presentations) {
|
|
167
180
|
const presentationDir = join(presentationsDir, id);
|
|
@@ -176,13 +189,55 @@ export async function deploy(options: DeployOptions = {}): Promise<void> {
|
|
|
176
189
|
console.log(` Done: ${id}\n`);
|
|
177
190
|
}
|
|
178
191
|
|
|
179
|
-
// Step 2: Generate
|
|
180
|
-
console.log(
|
|
192
|
+
// Step 2: Generate thumbnails for each presentation
|
|
193
|
+
console.log(`Step 2/${totalSteps}: Generating thumbnails...\n`);
|
|
194
|
+
|
|
195
|
+
if (!existsSync(thumbnailsDir)) {
|
|
196
|
+
mkdirSync(thumbnailsDir, { recursive: true });
|
|
197
|
+
}
|
|
181
198
|
|
|
182
|
-
|
|
199
|
+
for (const id of presentations) {
|
|
200
|
+
const presentationDir = join(presentationsDir, id);
|
|
201
|
+
const slidevBin = join(presentationDir, 'node_modules', '.bin', 'slidev');
|
|
202
|
+
const outputBase = join(thumbnailsDir, id);
|
|
203
|
+
const targetFile = join(thumbnailsDir, `${id}.png`);
|
|
204
|
+
|
|
205
|
+
console.log(` Thumbnail: ${id}`);
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
await runCommand(
|
|
209
|
+
slidevBin,
|
|
210
|
+
['export', '--format', 'png', '--range', '1', '--output', outputBase],
|
|
211
|
+
{
|
|
212
|
+
cwd: presentationDir,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Slidev exports into a directory <output>/<n>.png — move to <output>.png
|
|
217
|
+
if (!existsSync(targetFile) && existsSync(outputBase)) {
|
|
218
|
+
const pngs = readdirSync(outputBase)
|
|
219
|
+
.filter((f) => f.endsWith('.png'))
|
|
220
|
+
.sort();
|
|
221
|
+
if (pngs.length > 0) {
|
|
222
|
+
renameSync(join(outputBase, pngs[0]), targetFile);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (existsSync(targetFile)) {
|
|
227
|
+
await optimizeThumbnail(targetFile);
|
|
228
|
+
console.log(` Done: ${id}\n`);
|
|
229
|
+
} else {
|
|
230
|
+
console.warn(` Warning: Thumbnail for "${id}" could not be found after export.\n`);
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
console.warn(` Warning: Thumbnail generation failed for "${id}", skipping.\n`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
183
236
|
|
|
184
237
|
// Step 3: Build the Nuxt dashboard in static mode
|
|
185
|
-
|
|
238
|
+
// (runs before presentations.json because the Nitro generate plugin
|
|
239
|
+
// overwrites presentations.json without thumbnail data)
|
|
240
|
+
console.log(`Step 3/${totalSteps}: Building dashboard...\n`);
|
|
186
241
|
|
|
187
242
|
const nuxt = findNuxtBin(projectRoot, supaslidevRoot);
|
|
188
243
|
|
|
@@ -206,8 +261,16 @@ export async function deploy(options: DeployOptions = {}): Promise<void> {
|
|
|
206
261
|
env: nuxtEnv,
|
|
207
262
|
});
|
|
208
263
|
|
|
209
|
-
// Step 4:
|
|
210
|
-
console.log(
|
|
264
|
+
// Step 4: Generate presentations.json (after Nuxt build so it's not overwritten by Nitro plugin)
|
|
265
|
+
console.log(`\nStep 4/${totalSteps}: Generating presentations.json...\n`);
|
|
266
|
+
|
|
267
|
+
regeneratePresentationsJson(presentationsDir, presentationsJsonPath, {
|
|
268
|
+
thumbnailsDir,
|
|
269
|
+
basePath,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Step 5: Assemble output directory
|
|
273
|
+
console.log(`\nStep 5/${totalSteps}: Assembling deploy output...\n`);
|
|
211
274
|
|
|
212
275
|
// Clean and create output directory
|
|
213
276
|
if (existsSync(outputDir)) {
|
|
@@ -238,6 +301,22 @@ export async function deploy(options: DeployOptions = {}): Promise<void> {
|
|
|
238
301
|
}
|
|
239
302
|
}
|
|
240
303
|
|
|
304
|
+
// Copy thumbnails into output/thumbnails/
|
|
305
|
+
if (existsSync(thumbnailsDir)) {
|
|
306
|
+
const thumbnailsOutputDir = join(outputDir, 'thumbnails');
|
|
307
|
+
mkdirSync(thumbnailsOutputDir, { recursive: true });
|
|
308
|
+
|
|
309
|
+
for (const id of presentations) {
|
|
310
|
+
const webpFile = join(thumbnailsDir, `${id}.webp`);
|
|
311
|
+
const pngFile = join(thumbnailsDir, `${id}.png`);
|
|
312
|
+
if (existsSync(webpFile)) {
|
|
313
|
+
cpSync(webpFile, join(thumbnailsOutputDir, `${id}.webp`));
|
|
314
|
+
} else if (existsSync(pngFile)) {
|
|
315
|
+
cpSync(pngFile, join(thumbnailsOutputDir, `${id}.png`));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
241
320
|
// Copy presentations.json to output root
|
|
242
321
|
if (existsSync(presentationsJsonPath)) {
|
|
243
322
|
cpSync(presentationsJsonPath, join(outputDir, 'presentations.json'));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
|
|
4
|
+
import { findProjectRoot, getPresentations, printAvailablePresentations } from '../utils.js';
|
|
5
|
+
import { optimizeThumbnail } from '../../shared/optimize-thumbnail.js';
|
|
6
|
+
|
|
7
|
+
export interface ThumbnailOptions {
|
|
8
|
+
output?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function thumbnail(name: string, options: ThumbnailOptions = {}): Promise<void> {
|
|
12
|
+
const projectRoot = findProjectRoot();
|
|
13
|
+
|
|
14
|
+
if (!projectRoot) {
|
|
15
|
+
console.error('Error: Could not find a Supaslidev project.');
|
|
16
|
+
console.error('Make sure you are in a directory with a "presentations" folder.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const presentationsDir = join(projectRoot, 'presentations');
|
|
21
|
+
const thumbnailsDir = join(projectRoot, 'thumbnails');
|
|
22
|
+
const presentations = getPresentations(presentationsDir);
|
|
23
|
+
|
|
24
|
+
if (!presentations.includes(name)) {
|
|
25
|
+
console.error(`Error: Presentation "${name}" not found`);
|
|
26
|
+
printAvailablePresentations(presentations);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const presentationDir = join(presentationsDir, name);
|
|
31
|
+
const outputPath = options.output ?? join(thumbnailsDir, name);
|
|
32
|
+
|
|
33
|
+
if (!existsSync(dirname(outputPath))) {
|
|
34
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log('\n' + '='.repeat(50));
|
|
38
|
+
console.log(` Generating thumbnail: ${name}`);
|
|
39
|
+
console.log('='.repeat(50) + '\n');
|
|
40
|
+
|
|
41
|
+
const slidevBin = join(presentationDir, 'node_modules', '.bin', 'slidev');
|
|
42
|
+
|
|
43
|
+
await new Promise<void>((resolve) => {
|
|
44
|
+
const slidev = spawn(
|
|
45
|
+
slidevBin,
|
|
46
|
+
['export', '--format', 'png', '--range', '1', '--output', outputPath],
|
|
47
|
+
{
|
|
48
|
+
cwd: presentationDir,
|
|
49
|
+
stdio: 'inherit',
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
slidev.on('error', (err) => {
|
|
54
|
+
console.error(`Failed to generate thumbnail: ${err.message}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
slidev.on('close', (code) => {
|
|
59
|
+
if (code !== 0) {
|
|
60
|
+
console.error(`\nThumbnail generation failed with exit code ${code}`);
|
|
61
|
+
process.exit(code ?? 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Slidev exports into a directory <output>/<n>.png — move it to <output>.png
|
|
65
|
+
const targetFile = `${outputPath}.png`;
|
|
66
|
+
if (!existsSync(targetFile) && existsSync(outputPath)) {
|
|
67
|
+
const pngs = readdirSync(outputPath)
|
|
68
|
+
.filter((f) => f.endsWith('.png'))
|
|
69
|
+
.sort();
|
|
70
|
+
if (pngs.length > 0) {
|
|
71
|
+
renameSync(join(outputPath, pngs[0]), targetFile);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
resolve();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Optimize PNG to WebP
|
|
80
|
+
const pngFile = `${outputPath}.png`;
|
|
81
|
+
if (existsSync(pngFile)) {
|
|
82
|
+
const webpFile = await optimizeThumbnail(pngFile);
|
|
83
|
+
|
|
84
|
+
console.log('\n' + '='.repeat(50));
|
|
85
|
+
console.log(` Thumbnail generated!`);
|
|
86
|
+
console.log(` Output: ${webpFile}`);
|
|
87
|
+
console.log('='.repeat(50) + '\n');
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { present } from './commands/present.js';
|
|
|
7
7
|
import { exportPdf } from './commands/export.js';
|
|
8
8
|
import { importPresentation } from './commands/import.js';
|
|
9
9
|
import { deploy } from './commands/deploy.js';
|
|
10
|
+
import { thumbnail } from './commands/thumbnail.js';
|
|
10
11
|
|
|
11
12
|
const program = new Command();
|
|
12
13
|
|
|
@@ -57,6 +58,15 @@ program
|
|
|
57
58
|
await importPresentation(source, { name: options.name, install: options.install ?? true });
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
program
|
|
62
|
+
.command('thumbnail')
|
|
63
|
+
.description('Generate a PNG thumbnail of the first slide')
|
|
64
|
+
.argument('<name>', 'Name of the presentation')
|
|
65
|
+
.option('-o, --output <path>', 'Output path for the thumbnail (without extension)')
|
|
66
|
+
.action(async (name: string, options: { output?: string }) => {
|
|
67
|
+
await thumbnail(name, options);
|
|
68
|
+
});
|
|
69
|
+
|
|
60
70
|
program
|
|
61
71
|
.command('deploy')
|
|
62
72
|
.description('Build all presentations into a static deployable site')
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
const THUMBNAIL_WIDTH = 1280;
|
|
5
|
+
const WEBP_QUALITY = 80;
|
|
6
|
+
|
|
7
|
+
export async function optimizeThumbnail(pngPath: string): Promise<string> {
|
|
8
|
+
if (!existsSync(pngPath)) {
|
|
9
|
+
throw new Error(`Thumbnail not found: ${pngPath}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const webpPath = pngPath.replace(/\.png$/, '.webp');
|
|
13
|
+
|
|
14
|
+
await sharp(pngPath)
|
|
15
|
+
.resize(THUMBNAIL_WIDTH, undefined, { withoutEnlargement: true })
|
|
16
|
+
.webp({ quality: WEBP_QUALITY })
|
|
17
|
+
.toFile(webpPath);
|
|
18
|
+
|
|
19
|
+
// Remove the original PNG
|
|
20
|
+
unlinkSync(pngPath);
|
|
21
|
+
|
|
22
|
+
return webpPath;
|
|
23
|
+
}
|
|
@@ -64,9 +64,15 @@ export function extractDescription(info: string | undefined): string {
|
|
|
64
64
|
.join(' ');
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export interface RegenerateOptions {
|
|
68
|
+
thumbnailsDir?: string;
|
|
69
|
+
basePath?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
export function regeneratePresentationsJson(
|
|
68
73
|
presentationsDir: string,
|
|
69
74
|
presentationsJsonPath: string,
|
|
75
|
+
options: RegenerateOptions = {},
|
|
70
76
|
): void {
|
|
71
77
|
if (!existsSync(presentationsDir)) {
|
|
72
78
|
return;
|
|
@@ -87,7 +93,7 @@ export function regeneratePresentationsJson(
|
|
|
87
93
|
const content = readFileSync(slidesPath, 'utf-8');
|
|
88
94
|
const frontmatter = parseFrontmatter(content);
|
|
89
95
|
|
|
90
|
-
|
|
96
|
+
const presentation: Presentation = {
|
|
91
97
|
id: name,
|
|
92
98
|
title: frontmatter.title || name,
|
|
93
99
|
description: extractDescription(frontmatter.info) || '',
|
|
@@ -95,6 +101,19 @@ export function regeneratePresentationsJson(
|
|
|
95
101
|
background: frontmatter.background || '',
|
|
96
102
|
duration: frontmatter.duration || '',
|
|
97
103
|
};
|
|
104
|
+
|
|
105
|
+
if (options.thumbnailsDir) {
|
|
106
|
+
const base = (options.basePath ?? '/').replace(/\/*$/, '/');
|
|
107
|
+
const webpFile = join(options.thumbnailsDir, `${name}.webp`);
|
|
108
|
+
const pngFile = join(options.thumbnailsDir, `${name}.png`);
|
|
109
|
+
if (existsSync(webpFile)) {
|
|
110
|
+
presentation.thumbnail = `${base}thumbnails/${name}.webp`;
|
|
111
|
+
} else if (existsSync(pngFile)) {
|
|
112
|
+
presentation.thumbnail = `${base}thumbnails/${name}.png`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return presentation;
|
|
98
117
|
})
|
|
99
118
|
.sort((a, b) => a.title.localeCompare(b.title));
|
|
100
119
|
|