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/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ interface Presentation {
8
8
  theme: string;
9
9
  background: string;
10
10
  duration: string;
11
+ thumbnail?: string;
11
12
  }
12
13
  interface PackageJson {
13
14
  name?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supaslidev",
3
- "version": "0.3.6",
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.4.0",
51
- "commander": "^14.0.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
- "tailwindcss": "^4.1.18",
55
- "vue": "^3.5.31"
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.0.0",
62
- "tsdown": "^0.21.6",
63
- "tsx": "^4.19.0",
64
- "typescript": "^6.0.0",
65
- "vitest": "^4.0.0",
66
- "vue-tsc": "^3.0.0",
67
- "create-supaslidev": "^0.3.6"
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 { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
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/${4}: Building ${presentations.length} presentation(s)...\n`);
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 presentations.json
180
- console.log('Step 2/4: Generating presentations.json...\n');
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
- regeneratePresentationsJson(presentationsDir, presentationsJsonPath);
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
- console.log('Step 3/4: Building dashboard...\n');
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: Assemble output directory
210
- console.log('\nStep 4/4: Assembling deploy output...\n');
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
- return {
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
 
@@ -5,6 +5,7 @@ export interface Presentation {
5
5
  theme: string;
6
6
  background: string;
7
7
  duration: string;
8
+ thumbnail?: string;
8
9
  }
9
10
 
10
11
  export interface PackageJson {