create-openclass-uniminuto 1.4.2 → 1.6.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.
Files changed (26) hide show
  1. package/bin/create-openclass-uniminuto.mjs +81 -1
  2. package/package.json +2 -2
  3. package/template/README.md +326 -326
  4. package/template/package-lock.json +11568 -11568
  5. package/template/package.json +63 -61
  6. package/template/public/imagenes/avion.png +0 -0
  7. package/template/public/imagenes/favicon.png +0 -0
  8. package/template/scripts/generar-desde-config.mjs +348 -511
  9. package/template/theme/uniminuto/components/AutoFitText.vue +7 -2
  10. package/template/theme/uniminuto/components/TitleRibbon.vue +222 -0
  11. package/template/theme/uniminuto/layouts/README.md +99 -0
  12. package/template/theme/uniminuto/layouts/slide-02-titulo.vue +29 -47
  13. package/template/theme/uniminuto/layouts/slide-03-imagen-izquierda.vue +43 -69
  14. package/template/theme/uniminuto/layouts/slide-04-imagen-derecha.vue +49 -75
  15. package/template/theme/uniminuto/layouts/slide-05-titulo-superior-texto-derecha.vue +34 -66
  16. package/template/theme/uniminuto/layouts/slide-06-titulo-superior-texto-izquierda.vue +28 -78
  17. package/template/theme/uniminuto/layouts/slide-07-multimedia-con-titulo.vue +10 -66
  18. package/template/theme/uniminuto/layouts/slide-08-titulo-texto.vue +24 -53
  19. package/template/theme/uniminuto/layouts/slide-09-objetivos.vue +26 -53
  20. package/template/theme/uniminuto/layouts/slide-10-titulo-dos-columnas.vue +27 -59
  21. package/template/theme/uniminuto/layouts/slide-11-dos-titulos-dos-columnas.vue +32 -65
  22. package/template/theme/uniminuto/layouts/slide-codigo.vue +16 -113
  23. package/template/theme/uniminuto/styles/base.css +214 -109
  24. package/template/public/fondos/slide-02-titulo.png +0 -0
  25. package/template/public/fondos/slide-03-imagen-izquierda.png +0 -0
  26. package/template/public/fondos/slide-04-imagen-derecha.png +0 -0
@@ -1,511 +1,348 @@
1
- /**
2
- * Genera o actualiza una Open Class Slidev a partir de openclass.config.json.
3
- *
4
- * Uso principal:
5
- * npm run config
6
- * npm run semana -- 6
7
- * node scripts/generar-desde-config.mjs --config config/openclass.config.iot-ejemplo.json --force
8
- *
9
- * Mejoras:
10
- * - Mantiene el curso en 8 semanas.
11
- * - Repara public/favicon.png si no existe.
12
- * - Elimina demo_semanaX.md cuando el curso ya tiene un nombre corto diferente de "demo".
13
- * - No sobrescribe el contenido académico ya editado en semanas/*.md salvo con --force.
14
- */
15
- import fs from "node:fs";
16
- import path from "node:path";
17
-
18
- const args = process.argv.slice(2);
19
-
20
- function valueAfter(flag, fallback) {
21
- const i = args.indexOf(flag);
22
- if (i >= 0 && args[i + 1]) return args[i + 1];
23
-
24
- const pair = args.find((arg) => arg.startsWith(`${flag}=`));
25
- if (pair) return pair.slice(flag.length + 1);
26
-
27
- return fallback;
28
- }
29
-
30
- const configPath = valueAfter("--config", "openclass.config.json");
31
- const force = args.includes("--force") || args.includes("-f");
32
- const dryRun = args.includes("--dry-run");
33
- const forceCleanDemo = args.includes("--clean-demo");
34
-
35
- /**
36
- * Favicon PNG mínimo de respaldo.
37
- *
38
- * No reemplaza un favicon existente.
39
- * Solo se usa si public/favicon.png no existe, para evitar que Slidev o GitHub Pages
40
- * queden apuntando a un recurso inexistente.
41
- */
42
- const FALLBACK_FAVICON_PNG_BASE64 =
43
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
44
-
45
- function fail(message) {
46
- console.error(`\n❌ ${message}\n`);
47
- process.exit(1);
48
- }
49
-
50
- function readJson(filePath) {
51
- if (!fs.existsSync(filePath)) {
52
- fail(`No se encontró el archivo de configuración: ${filePath}`);
53
- }
54
-
55
- try {
56
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
57
- } catch (error) {
58
- fail(`No se pudo leer ${filePath}. Revisa que sea JSON válido.\n${error.message}`);
59
- }
60
- }
61
-
62
- function ensureDir(dir) {
63
- if (!dryRun) fs.mkdirSync(dir, { recursive: true });
64
- }
65
-
66
- function writeFileSafe(filePath, content, { overwrite = true, label = filePath } = {}) {
67
- ensureDir(path.dirname(filePath));
68
-
69
- if (fs.existsSync(filePath) && !overwrite) {
70
- console.log(`⏭ Conservado: ${label}`);
71
- return false;
72
- }
73
-
74
- if (!dryRun) fs.writeFileSync(filePath, content, "utf-8");
75
- console.log(`${dryRun ? "🔎" : "✓"} ${label}`);
76
- return true;
77
- }
78
-
79
- function writeBinaryFileIfMissing(filePath, base64Content, { label = filePath } = {}) {
80
- ensureDir(path.dirname(filePath));
81
-
82
- if (fs.existsSync(filePath)) {
83
- console.log(`⏭ Conservado: ${label}`);
84
- return false;
85
- }
86
-
87
- if (!dryRun) {
88
- fs.writeFileSync(filePath, Buffer.from(base64Content, "base64"));
89
- }
90
-
91
- console.log(`${dryRun ? "🔎" : "✓"} ${label} creado como respaldo`);
92
- return true;
93
- }
94
-
95
- function removeFileIfExists(filePath, { label = filePath } = {}) {
96
- if (!fs.existsSync(filePath)) return false;
97
-
98
- if (!dryRun) fs.rmSync(filePath, { force: true });
99
- console.log(`${dryRun ? "🔎" : "🧹"} Eliminado: ${label}`);
100
- return true;
101
- }
102
-
103
- function cleanShortName(value) {
104
- return (
105
- String(value || "")
106
- .normalize("NFD")
107
- .replace(/[\u0300-\u036f]/g, "")
108
- .toLowerCase()
109
- .replace(/[^a-z0-9]+/g, "")
110
- .trim() || "curso"
111
- );
112
- }
113
-
114
- function asArray(value) {
115
- return Array.isArray(value) ? value : [];
116
- }
117
-
118
- function replaceTokens(template, tokens) {
119
- return Object.entries(tokens).reduce(
120
- (content, [key, value]) => content.replaceAll(`{{${key}}}`, String(value ?? "")),
121
- template,
122
- );
123
- }
124
-
125
- function readTemplate(filePath, fallback) {
126
- return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : fallback;
127
- }
128
-
129
- const config = readJson(configPath);
130
- const course = config.course || {};
131
- const generation = config.generation || {};
132
-
133
- const courseShort = cleanShortName(course.shortName || config.shortName || config.courseCode || "curso");
134
- const courseName = String(course.fullName || course.name || config.courseName || "Nombre del curso").trim();
135
- const courseYear = String(course.year || config.year || new Date().getFullYear()).trim();
136
- const description = String(course.description || config.description || "Agrega aquí la descripción general del curso.").trim();
137
- const openClassLabel = String(course.openClassLabel || "Open Class").trim();
138
- const weeksTotal = Number(generation.weeksTotal || config.weeksTotal || config.totalWeeks || asArray(config.weeks).length || 8);
139
-
140
- if (!courseShort) fail("El campo course.shortName es obligatorio.");
141
- if (!courseName) fail("El campo course.fullName es obligatorio.");
142
- if (!Number.isInteger(weeksTotal) || weeksTotal < 1) {
143
- fail("generation.weeksTotal debe ser un número entero mayor o igual a 1.");
144
- }
145
-
146
- const configuredWeeks = new Map(asArray(config.weeks).map((week) => [Number(week.number), week]));
147
-
148
- function weekInfo(number) {
149
- const week = configuredWeeks.get(number) || {};
150
- const title = String(week.title || `Título semana ${number}`).trim();
151
-
152
- return {
153
- number,
154
- title,
155
- date: String(week.date || courseYear).trim(),
156
- centralTheme: String(week.centralTheme || week.theme || title).trim(),
157
- activity: String(week.activity || "Actividad o evaluación relacionada").trim(),
158
- duration: String(week.duration || "90 minutos").trim(),
159
- status: String(week.status || "active").trim(),
160
- };
161
- }
162
-
163
- const allWeeks = Array.from({ length: weeksTotal }, (_, i) => weekInfo(i + 1));
164
-
165
- const activeWeeksRaw = asArray(generation.activeWeeks || config.activeWeeks);
166
- const activeWeekNumbers = activeWeeksRaw.length
167
- ? activeWeeksRaw.map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= weeksTotal)
168
- : allWeeks.filter((week) => week.status !== "draft" && week.status !== "inactive").map((week) => week.number);
169
-
170
- const activeWeeks = allWeeks.filter((week) => activeWeekNumbers.includes(week.number));
171
-
172
- const overwriteLaunchers = generation.overwriteLaunchers !== false || force;
173
- const overwritePortal = generation.overwritePortal !== false || force;
174
- const overwriteDecks = generation.overwriteDecks !== false || force;
175
- const overwritePackageScripts = generation.overwritePackageScripts !== false || force;
176
- const overwriteWeekContent = generation.overwriteWeekContent === true || force;
177
- const exportPortal = generation.exportPortal === true;
178
-
179
- /**
180
- * Por defecto se eliminan los archivos demo cuando el curso ya no se llama "demo".
181
- * Puede desactivarse desde openclass.config.json:
182
- *
183
- * "generation": {
184
- * "cleanDemoFiles": false
185
- * }
186
- */
187
- const cleanDemoFiles = generation.cleanDemoFiles !== false;
188
-
189
- const semanaTemplate = readTemplate(
190
- "plantillas/semana.md",
191
- `---
192
- layout: slide-01-portada
193
- ---
194
-
195
- ::title::
196
- {{COURSE_NAME}}
197
-
198
- ::week::
199
- Semana {{WEEK_NUMBER}} {{WEEK_TITLE}}
200
-
201
- ::date::
202
- {{WEEK_DATE}}
203
-
204
- ---
205
- layout: slide-12-cierre
206
- ---
207
- `,
208
- );
209
-
210
- const launcherTemplate = readTemplate(
211
- "plantillas/launcher.md",
212
- `---
213
- theme: ./theme/uniminuto
214
- title: {{COURSE_NAME}} Semana {{WEEK_NUMBER}} — {{WEEK_TITLE}}
215
- favicon: /favicon.png
216
- codeCopy: true
217
- transition: fade
218
- routerMode: hash
219
- drawings:
220
- persist: false
221
- src: ./semanas/{{COURSE_SHORT}}_semana{{WEEK_NUMBER}}.md
222
- ---
223
- `,
224
- );
225
-
226
- function weekTokens(week) {
227
- return {
228
- COURSE_SHORT: courseShort,
229
- COURSE_NAME: courseName,
230
- COURSE_YEAR: courseYear,
231
- OPEN_CLASS_LABEL: openClassLabel,
232
- WEEK_NUMBER: week.number,
233
- WEEK_TITLE: week.title,
234
- WEEK_DATE: week.date,
235
- WEEK_THEME: week.centralTheme,
236
- WEEK_ACTIVITY: week.activity,
237
- WEEK_DURATION: week.duration,
238
- };
239
- }
240
-
241
- function portalWeekItem(week) {
242
- const slug = `${courseShort}_semana${week.number}`;
243
-
244
- return `### **Semana ${week.number}**
245
-
246
- <a href="./semanas/${slug}/#/1" target="_self">${week.title}</a>
247
-
248
- <a href="./descargas/${slug}.pdf" download>Descargar PDF</a> · <a href="./descargas/${slug}.pptx" download>Descargar PPTX</a>`;
249
- }
250
-
251
- function buildPortal() {
252
- const left = activeWeeks.length
253
- ? activeWeeks
254
- .filter((_, index) => index < Math.ceil(activeWeeks.length / 2))
255
- .map(portalWeekItem)
256
- .join("\n\n")
257
- : "### Sin semanas activas\n\nEjecuta `npm run semana -- 1` para generar la primera semana.";
258
-
259
- const right =
260
- activeWeeks
261
- .filter((_, index) => index >= Math.ceil(activeWeeks.length / 2))
262
- .map(portalWeekItem)
263
- .join("\n\n") ||
264
- "### Próximamente\n\nActiva más semanas con `npm run semana -- 2`, `npm run semana -- 3` y así sucesivamente.";
265
-
266
- return `---
267
- theme: ./theme/uniminuto
268
- title: ${courseName} — ${openClassLabel}
269
- favicon: /favicon.png
270
- codeCopy: true
271
- transition: fade
272
- routerMode: hash
273
- drawings:
274
- persist: false
275
- layout: slide-01-portada
276
- ---
277
-
278
- ::title::
279
- ${courseName}
280
-
281
- ::week::
282
- ${openClassLabel}
283
-
284
- ::date::
285
- ${courseYear}
286
-
287
- ---
288
- layout: slide-08-titulo-texto
289
- ---
290
-
291
- ::title::
292
- Descripción general del curso
293
-
294
- ::content::
295
- ${description}
296
-
297
- ---
298
- layout: slide-08-titulo-texto
299
- ---
300
-
301
- ::title::
302
- Ruta de aprendizaje
303
-
304
- ::content::
305
- El curso se organiza en semanas. Cada semana cuenta con un lanzador raíz y un archivo interno en la carpeta \`semanas/\`.
306
-
307
- Las presentaciones activas se controlan desde \`openclass.config.json\` mediante el campo \`generation.activeWeeks\`.
308
-
309
- ---
310
- layout: slide-10-titulo-dos-columnas
311
- ---
312
-
313
- ::title::
314
- Presentaciones disponibles
315
-
316
- ::left::
317
- ${left}
318
-
319
- ::right::
320
- ${right}
321
-
322
- ---
323
- layout: slide-12-cierre
324
- ---
325
- `;
326
- }
327
-
328
- function buildDecks() {
329
- const entries = [
330
- `function normalizeBase(value) {
331
- let base = value || "/";
332
- if (!base.startsWith("/")) base = \`/\${base}\`;
333
- if (!base.endsWith("/")) base = \`\${base}/\`;
334
- return base;
335
- }
336
-
337
- const SITE_BASE = normalizeBase(process.env.SITE_BASE || "/");
338
-
339
- function withBase(path = "") {
340
- return \`\${SITE_BASE}\${path.replace(/^\\/+/, "")}\`;
341
- }
342
-
343
- export const decks = [
344
- {
345
- name: "openclass-${courseShort}",
346
- entry: "slides.md",
347
- out: "dist",
348
- base: SITE_BASE,
349
- exportable: ${exportPortal ? "true" : "false"},
350
- },`,
351
- ];
352
-
353
- for (const week of activeWeeks) {
354
- const slug = `${courseShort}_semana${week.number}`;
355
-
356
- entries.push(` {
357
- name: "${slug}",
358
- entry: "${slug}.md",
359
- out: "dist/semanas/${slug}",
360
- base: withBase("semanas/${slug}/"),
361
- exportable: true,
362
- },`);
363
- }
364
-
365
- entries.push(`];\n`);
366
- return entries.join("\n");
367
- }
368
-
369
- function buildScripts() {
370
- const scripts = {
371
- dev: "slidev slides.md --open --port 3000",
372
- config: "node scripts/generar-desde-config.mjs",
373
- semana: "node scripts/semana.mjs",
374
- "pages:check": "node scripts/preparar-github-pages.mjs",
375
- };
376
-
377
- for (let i = 1; i <= weeksTotal; i++) {
378
- const slug = `${courseShort}_semana${i}`;
379
- scripts[`dev:s${i}`] = `slidev ${slug}.md --open --port ${3000 + i}`;
380
- }
381
-
382
- scripts.clean = "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"";
383
- scripts["clean:downloads"] = "node -e \"require('fs').rmSync('public/descargas',{recursive:true,force:true}); require('fs').mkdirSync('public/descargas',{recursive:true})\"";
384
- scripts["clean:cache"] = "node -e \"require('fs').rmSync('.slidev',{recursive:true,force:true}); require('fs').rmSync('node_modules/.vite',{recursive:true,force:true})\"";
385
- scripts["build:index"] = "slidev build slides.md --out dist --base / --without-notes";
386
-
387
- for (let i = 1; i <= weeksTotal; i++) {
388
- const slug = `${courseShort}_semana${i}`;
389
- scripts[`build:s${i}`] = `slidev build ${slug}.md --out dist/semanas/${slug} --base /semanas/${slug}/ --without-notes`;
390
- }
391
-
392
- scripts["build:all"] = "node scripts/build-site.mjs";
393
- scripts["build:incremental"] = "node scripts/build-incremental.mjs";
394
- scripts["export:downloads"] = "node scripts/export-downloads.mjs";
395
- scripts["export:incremental"] = "node scripts/export-incremental.mjs";
396
- scripts.vista = "npm run clean && npm run export:downloads && npm run build:all && http-server dist -p 4173 -c-1 -o";
397
- scripts.publicar = "node scripts/publicar.mjs";
398
- scripts["dev:todo"] = "node scripts/dev-all.mjs";
399
- scripts.nuevo = "node scripts/nuevo-curso.mjs";
400
- scripts["zip:template"] = "node scripts/zip-template.mjs";
401
- scripts["preview:static"] = "npm run build:all && http-server dist -p 4173 -c-1";
402
- scripts["preview:pages"] = "npm run export:downloads && npm run build:all && http-server dist -p 4173 -c-1";
403
- scripts["pages:build"] = "npm run export:downloads && npm run build:all";
404
- scripts["pages:preview"] = "npm run preview:pages";
405
-
406
- return scripts;
407
- }
408
-
409
- function ensureProjectAssets() {
410
- ensureDir("public");
411
- ensureDir("public/descargas");
412
- ensureDir("public/imagenes");
413
- ensureDir("public/videos");
414
-
415
- writeBinaryFileIfMissing("public/favicon.png", FALLBACK_FAVICON_PNG_BASE64, {
416
- label: "public/favicon.png",
417
- });
418
- }
419
-
420
- function cleanLegacyDemoFiles() {
421
- const shouldClean = forceCleanDemo || (cleanDemoFiles && courseShort !== "demo");
422
-
423
- if (!shouldClean) {
424
- console.log("⏭ Archivos demo conservados.");
425
- return;
426
- }
427
-
428
- for (let i = 1; i <= 8; i++) {
429
- removeFileIfExists(`demo_semana${i}.md`, {
430
- label: `demo_semana${i}.md`,
431
- });
432
-
433
- removeFileIfExists(path.join("semanas", `demo_semana${i}.md`), {
434
- label: path.join("semanas", `demo_semana${i}.md`),
435
- });
436
- }
437
- }
438
-
439
- console.log("\n┌──────────────────────────────────────────────┐");
440
- console.log("│ Generador Open Class UNIMINUTO desde config │");
441
- console.log("└──────────────────────────────────────────────┘\n");
442
- console.log(`Configuración : ${configPath}`);
443
- console.log(`Curso : ${courseName}`);
444
- console.log(`Nombre corto : ${courseShort}`);
445
- console.log(`Semanas : ${weeksTotal}`);
446
- console.log(`Activas : ${activeWeeks.map((w) => w.number).join(", ") || "ninguna"}\n`);
447
-
448
- ensureDir("semanas");
449
- ensureProjectAssets();
450
- cleanLegacyDemoFiles();
451
-
452
- writeFileSafe("slides.md", buildPortal(), {
453
- overwrite: overwritePortal,
454
- label: "slides.md",
455
- });
456
-
457
- writeFileSafe("scripts/decks.mjs", buildDecks(), {
458
- overwrite: overwriteDecks,
459
- label: "scripts/decks.mjs",
460
- });
461
-
462
- for (const week of allWeeks) {
463
- const slug = `${courseShort}_semana${week.number}`;
464
- const tokens = weekTokens(week);
465
-
466
- writeFileSafe(`${slug}.md`, replaceTokens(launcherTemplate, tokens), {
467
- overwrite: overwriteLaunchers,
468
- label: `${slug}.md`,
469
- });
470
-
471
- writeFileSafe(path.join("semanas", `${slug}.md`), replaceTokens(semanaTemplate, tokens), {
472
- overwrite: overwriteWeekContent,
473
- label: path.join("semanas", `${slug}.md`),
474
- });
475
- }
476
-
477
- if (overwritePackageScripts && fs.existsSync("package.json")) {
478
- const pkg = JSON.parse(fs.readFileSync("package.json", "utf-8"));
479
-
480
- pkg.name = `openclass-${courseShort}`;
481
- pkg.description = `Presentaciones Open Class del curso ${courseName}.`;
482
- pkg.scripts = buildScripts();
483
-
484
- writeFileSafe("package.json", JSON.stringify(pkg, null, 2) + "\n", {
485
- overwrite: true,
486
- label: "package.json",
487
- });
488
- }
489
-
490
- if (fs.existsSync("package-lock.json")) {
491
- try {
492
- const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf-8"));
493
-
494
- lock.name = `openclass-${courseShort}`;
495
- if (lock.packages && lock.packages[""]) {
496
- lock.packages[""].name = `openclass-${courseShort}`;
497
- }
498
-
499
- writeFileSafe("package-lock.json", JSON.stringify(lock, null, 2) + "\n", {
500
- overwrite: true,
501
- label: "package-lock.json",
502
- });
503
- } catch {
504
- console.log("⚠️ package-lock.json no se actualizó porque no parece ser JSON válido.");
505
- }
506
- }
507
-
508
- console.log("\n✅ Configuración generada.");
509
- console.log(" Vista de una semana: npm run dev:s1");
510
- console.log(" Vista completa: npm run vista");
511
- console.log(" Exportar PDF/PPTX: npm run export:downloads\n");
1
+ /**
2
+ * Genera o actualiza una Open Class Slidev a partir de openclass.config.json.
3
+ *
4
+ * Uso principal:
5
+ * npm run config
6
+ * node scripts/generar-desde-config.mjs --config config/openclass.config.iot-ejemplo.json --force
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ const args = process.argv.slice(2);
12
+
13
+ function valueAfter(flag, fallback) {
14
+ const i = args.indexOf(flag);
15
+ if (i >= 0 && args[i + 1]) return args[i + 1];
16
+ const pair = args.find((arg) => arg.startsWith(`${flag}=`));
17
+ if (pair) return pair.slice(flag.length + 1);
18
+ return fallback;
19
+ }
20
+
21
+ const configPath = valueAfter("--config", "openclass.config.json");
22
+ const force = args.includes("--force") || args.includes("-f");
23
+ const dryRun = args.includes("--dry-run");
24
+ const forceCleanDemo = args.includes("--clean-demo");
25
+ const FALLBACK_FAVICON_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
26
+
27
+ function fail(message) {
28
+ console.error(`\n❌ ${message}\n`);
29
+ process.exit(1);
30
+ }
31
+
32
+ function readJson(filePath) {
33
+ if (!fs.existsSync(filePath)) fail(`No se encontró el archivo de configuración: ${filePath}`);
34
+ try {
35
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
36
+ } catch (error) {
37
+ fail(`No se pudo leer ${filePath}. Revisa que sea JSON válido.\n${error.message}`);
38
+ }
39
+ }
40
+
41
+ function ensureDir(dir) {
42
+ if (!dryRun) fs.mkdirSync(dir, { recursive: true });
43
+ }
44
+
45
+ function writeFileSafe(filePath, content, { overwrite = true, label = filePath } = {}) {
46
+ ensureDir(path.dirname(filePath));
47
+
48
+ if (fs.existsSync(filePath) && !overwrite) {
49
+ console.log(`⏭ Conservado: ${label}`);
50
+ return false;
51
+ }
52
+
53
+ if (!dryRun) fs.writeFileSync(filePath, content, "utf-8");
54
+ console.log(`${dryRun ? "🔎" : "✓"} ${label}`);
55
+ return true;
56
+ }
57
+
58
+ function writeBinaryFileIfMissing(filePath, bufferOrBase64, { label = filePath } = {}) {
59
+ ensureDir(path.dirname(filePath));
60
+
61
+ if (fs.existsSync(filePath)) {
62
+ console.log(`⏭ Conservado: ${label}`);
63
+ return false;
64
+ }
65
+
66
+ if (!dryRun) {
67
+ const buffer = Buffer.isBuffer(bufferOrBase64)
68
+ ? bufferOrBase64
69
+ : Buffer.from(bufferOrBase64, "base64");
70
+ fs.writeFileSync(filePath, buffer);
71
+ }
72
+
73
+ console.log(`${dryRun ? "🔎" : "✓"} ${label} creado`);
74
+ return true;
75
+ }
76
+
77
+ function removeFileIfExists(filePath, { label = filePath } = {}) {
78
+ if (!fs.existsSync(filePath)) return false;
79
+ if (!dryRun) fs.rmSync(filePath, { force: true });
80
+ console.log(`${dryRun ? "🔎" : "🧹"} Eliminado: ${label}`);
81
+ return true;
82
+ }
83
+
84
+ function cleanShortName(value) {
85
+ return String(value || "")
86
+ .normalize("NFD")
87
+ .replace(/[\u0300-\u036f]/g, "")
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/g, "")
90
+ .trim();
91
+ }
92
+
93
+ function asArray(value) {
94
+ return Array.isArray(value) ? value : [];
95
+ }
96
+
97
+ function replaceTokens(template, tokens) {
98
+ return Object.entries(tokens).reduce(
99
+ (content, [key, value]) => content.replaceAll(`{{${key}}}`, String(value ?? "")),
100
+ template,
101
+ );
102
+ }
103
+
104
+ function readTemplate(filePath, fallback) {
105
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : fallback;
106
+ }
107
+
108
+ function normalizeBase(value) {
109
+ let base = value || "/";
110
+ if (!base.startsWith("/")) base = `/${base}`;
111
+ if (!base.endsWith("/")) base = `${base}/`;
112
+ return base;
113
+ }
114
+
115
+ const config = readJson(configPath);
116
+ const course = config.course || {};
117
+ const generation = config.generation || {};
118
+
119
+ const courseShort = cleanShortName(course.shortName || config.shortName || config.courseCode || "curso");
120
+ const courseName = String(course.fullName || course.name || config.courseName || "Nombre del curso").trim();
121
+ const courseYear = String(course.year || config.year || new Date().getFullYear()).trim();
122
+ const description = String(course.description || config.description || "Agrega aquí la descripción general del curso.").trim();
123
+ const openClassLabel = String(course.openClassLabel || "Open Class").trim();
124
+ const weeksTotal = Number(generation.weeksTotal || config.weeksTotal || config.totalWeeks || asArray(config.weeks).length || 8);
125
+
126
+ if (!courseShort) fail("El campo course.shortName es obligatorio.");
127
+ if (!courseName) fail("El campo course.fullName es obligatorio.");
128
+ if (!Number.isInteger(weeksTotal) || weeksTotal < 1) fail("generation.weeksTotal debe ser un número entero mayor o igual a 1.");
129
+
130
+ const configuredWeeks = new Map(asArray(config.weeks).map((week) => [Number(week.number), week]));
131
+
132
+ function weekInfo(number) {
133
+ const week = configuredWeeks.get(number) || {};
134
+ const title = String(week.title || `Título semana ${number}`).trim();
135
+ return {
136
+ number,
137
+ title,
138
+ date: String(week.date || courseYear).trim(),
139
+ centralTheme: String(week.centralTheme || week.theme || title).trim(),
140
+ activity: String(week.activity || "Actividad o evaluación relacionada").trim(),
141
+ duration: String(week.duration || "90 minutos").trim(),
142
+ status: String(week.status || "active").trim(),
143
+ };
144
+ }
145
+
146
+ const allWeeks = Array.from({ length: weeksTotal }, (_, i) => weekInfo(i + 1));
147
+ const activeWeeksRaw = asArray(generation.activeWeeks || config.activeWeeks);
148
+ const activeWeekNumbers = activeWeeksRaw.length
149
+ ? activeWeeksRaw.map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= weeksTotal)
150
+ : allWeeks.filter((week) => week.status !== "draft" && week.status !== "inactive").map((week) => week.number);
151
+
152
+ const activeWeeks = allWeeks.filter((week) => activeWeekNumbers.includes(week.number));
153
+ const overwriteLaunchers = generation.overwriteLaunchers !== false || force;
154
+ const overwritePortal = generation.overwritePortal !== false || force;
155
+ const overwriteDecks = generation.overwriteDecks !== false || force;
156
+ const overwritePackageScripts = generation.overwritePackageScripts !== false || force;
157
+ const overwriteWeekContent = generation.overwriteWeekContent === true || force;
158
+ const exportPortal = generation.exportPortal === true;
159
+
160
+ const semanaTemplate = readTemplate(
161
+ "plantillas/semana.md",
162
+ `---\nlayout: slide-01-portada\n---\n\n::title::\n{{COURSE_NAME}}\n\n::week::\nSemana {{WEEK_NUMBER}} — {{WEEK_TITLE}}\n\n::date::\n{{WEEK_DATE}}\n\n---\nlayout: slide-12-cierre\n---\n`,
163
+ );
164
+
165
+ const launcherTemplate = readTemplate(
166
+ "plantillas/launcher.md",
167
+ `---\ntheme: ./theme/uniminuto\ntitle: {{COURSE_NAME}} Semana {{WEEK_NUMBER}} {{WEEK_TITLE}}\nfavicon: /favicon.png\ncodeCopy: true\ntransition: fade\nrouterMode: hash\ndrawings:\n persist: false\nsrc: ./semanas/{{COURSE_SHORT}}_semana{{WEEK_NUMBER}}.md\n---\n`,
168
+ );
169
+
170
+ function weekTokens(week) {
171
+ return {
172
+ COURSE_SHORT: courseShort,
173
+ COURSE_NAME: courseName,
174
+ COURSE_YEAR: courseYear,
175
+ OPEN_CLASS_LABEL: openClassLabel,
176
+ WEEK_NUMBER: week.number,
177
+ WEEK_TITLE: week.title,
178
+ WEEK_DATE: week.date,
179
+ WEEK_THEME: week.centralTheme,
180
+ WEEK_ACTIVITY: week.activity,
181
+ WEEK_DURATION: week.duration,
182
+ };
183
+ }
184
+
185
+ function portalWeekItem(week) {
186
+ const slug = `${courseShort}_semana${week.number}`;
187
+ return `### **Semana ${week.number}**\n\n<a href="./semanas/${slug}/#/1" target="_self">${week.title}</a>\n\n<a href="./descargas/${slug}.pdf" download>Descargar PDF</a> · <a href="./descargas/${slug}.pptx" download>Descargar PPTX</a>`;
188
+ }
189
+
190
+ function buildPortal() {
191
+ const left = activeWeeks.length
192
+ ? activeWeeks.filter((_, index) => index < Math.ceil(activeWeeks.length / 2)).map(portalWeekItem).join("\n\n")
193
+ : "### Sin semanas activas\n\nEjecuta `npm run semana -- 1` para generar la primera semana.";
194
+ const right = activeWeeks.filter((_, index) => index >= Math.ceil(activeWeeks.length / 2)).map(portalWeekItem).join("\n\n") || "### Próximamente\n\nActiva más semanas con `npm run semana -- 2`, `npm run semana -- 3` y así sucesivamente.";
195
+
196
+ return `---\ntheme: ./theme/uniminuto\ntitle: ${courseName} — ${openClassLabel}\nfavicon: /favicon.png\ncodeCopy: true\ntransition: fade\nrouterMode: hash\ndrawings:\n persist: false\nlayout: slide-01-portada\n---\n\n::title::\n${courseName}\n\n::week::\n${openClassLabel}\n\n::date::\n${courseYear}\n\n---\nlayout: slide-08-titulo-texto\n---\n\n::title::\nDescripción general del curso\n\n::content::\n${description}\n\n---\nlayout: slide-08-titulo-texto\n---\n\n::title::\nRuta de aprendizaje\n\n::content::\nEl curso se organiza en semanas. Cada semana cuenta con un lanzador raíz y un archivo interno en la carpeta \`semanas/\`.\n\nLas presentaciones activas se controlan desde \`openclass.config.json\` mediante el campo \`generation.activeWeeks\`.\n\n---\nlayout: slide-10-titulo-dos-columnas\n---\n\n::title::\nPresentaciones disponibles\n\n::left::\n${left}\n\n::right::\n${right}\n\n---\nlayout: slide-12-cierre\n---\n`;
197
+ }
198
+
199
+ function buildDecks() {
200
+ const entries = [
201
+ `function normalizeBase(value) {\n let base = value || "/";\n if (!base.startsWith("/")) base = \`/\${base}\`;\n if (!base.endsWith("/")) base = \`\${base}/\`;\n return base;\n}\n\nconst SITE_BASE = normalizeBase(process.env.SITE_BASE || "/");\n\nfunction withBase(path = "") {\n return \`\${SITE_BASE}\${path.replace(/^\\/+/, "")}\`;\n}\n\nexport const decks = [\n {\n name: "openclass-${courseShort}",\n entry: "slides.md",\n out: "dist",\n base: SITE_BASE,\n exportable: ${exportPortal ? "true" : "false"},\n },`,
202
+ ];
203
+
204
+ for (const week of activeWeeks) {
205
+ const slug = `${courseShort}_semana${week.number}`;
206
+ entries.push(` {\n name: "${slug}",\n entry: "${slug}.md",\n out: "dist/semanas/${slug}",\n base: withBase("semanas/${slug}/"),\n exportable: true,\n },`);
207
+ }
208
+
209
+ entries.push(`];\n`);
210
+ return entries.join("\n");
211
+ }
212
+
213
+ function buildScripts() {
214
+ const scripts = {
215
+ dev: "slidev slides.md --open --port 3000",
216
+ config: "node scripts/generar-desde-config.mjs",
217
+ semana: "node scripts/semana.mjs",
218
+ "pages:check": "node scripts/preparar-github-pages.mjs",
219
+ "actualizar:tema": "npm create openclass-uniminuto@latest . -- --update-theme",
220
+ "actualizar:tema:preview": "npm run actualizar:tema && npm run dev",
221
+ };
222
+
223
+ for (let i = 1; i <= weeksTotal; i++) {
224
+ const slug = `${courseShort}_semana${i}`;
225
+ scripts[`dev:s${i}`] = `slidev ${slug}.md --open --port ${3000 + i}`;
226
+ }
227
+
228
+ scripts.clean = "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"";
229
+ scripts["clean:downloads"] = "node -e \"require('fs').rmSync('public/descargas',{recursive:true,force:true}); require('fs').mkdirSync('public/descargas',{recursive:true})\"";
230
+ scripts["clean:cache"] = "node -e \"require('fs').rmSync('.slidev',{recursive:true,force:true}); require('fs').rmSync('node_modules/.vite',{recursive:true,force:true})\"";
231
+ scripts["build:index"] = "slidev build slides.md --out dist --base / --without-notes";
232
+
233
+ for (let i = 1; i <= weeksTotal; i++) {
234
+ const slug = `${courseShort}_semana${i}`;
235
+ scripts[`build:s${i}`] = `slidev build ${slug}.md --out dist/semanas/${slug} --base /semanas/${slug}/ --without-notes`;
236
+ }
237
+
238
+ scripts["build:all"] = "node scripts/build-site.mjs";
239
+ scripts["build:incremental"] = "node scripts/build-incremental.mjs";
240
+ scripts["export:downloads"] = "node scripts/export-downloads.mjs";
241
+ scripts["export:incremental"] = "node scripts/export-incremental.mjs";
242
+ scripts.vista = "npm run clean && npm run export:downloads && npm run build:all && http-server dist -p 4173 -c-1 -o";
243
+ scripts.publicar = "node scripts/publicar.mjs";
244
+ scripts["dev:todo"] = "node scripts/dev-all.mjs";
245
+ scripts.nuevo = "node scripts/nuevo-curso.mjs";
246
+ scripts["zip:template"] = "node scripts/zip-template.mjs";
247
+ scripts["preview:static"] = "npm run build:all && http-server dist -p 4173 -c-1";
248
+ scripts["preview:pages"] = "npm run export:downloads && npm run build:all && http-server dist -p 4173 -c-1";
249
+ scripts["pages:build"] = "npm run export:downloads && npm run build:all";
250
+ scripts["pages:preview"] = "npm run preview:pages";
251
+
252
+ return scripts;
253
+ }
254
+
255
+ function ensureProjectAssets() {
256
+ ensureDir("public");
257
+ ensureDir("public/descargas");
258
+ ensureDir("public/imagenes");
259
+ ensureDir("public/videos");
260
+
261
+ const rootFavicon = "public/favicon.png";
262
+ const imageFavicon = "public/imagenes/favicon.png";
263
+
264
+ if (fs.existsSync(rootFavicon) && !fs.existsSync(imageFavicon)) {
265
+ if (!dryRun) fs.copyFileSync(rootFavicon, imageFavicon);
266
+ console.log(`${dryRun ? "🔎" : "✓"} ${imageFavicon} creado desde ${rootFavicon}`);
267
+ }
268
+
269
+ if (fs.existsSync(imageFavicon) && !fs.existsSync(rootFavicon)) {
270
+ if (!dryRun) fs.copyFileSync(imageFavicon, rootFavicon);
271
+ console.log(`${dryRun ? "🔎" : "✓"} ${rootFavicon} creado desde ${imageFavicon}`);
272
+ }
273
+
274
+ if (!fs.existsSync(rootFavicon) && !fs.existsSync(imageFavicon)) {
275
+ writeBinaryFileIfMissing(rootFavicon, FALLBACK_FAVICON_PNG_BASE64, { label: rootFavicon });
276
+ writeBinaryFileIfMissing(imageFavicon, FALLBACK_FAVICON_PNG_BASE64, { label: imageFavicon });
277
+ }
278
+ }
279
+
280
+ function cleanLegacyDemoFiles() {
281
+ const shouldClean = forceCleanDemo || courseShort !== "demo";
282
+ if (!shouldClean) {
283
+ console.log("⏭ Archivos demo conservados.");
284
+ return;
285
+ }
286
+
287
+ for (let i = 1; i <= 8; i += 1) {
288
+ removeFileIfExists(`demo_semana${i}.md`, { label: `demo_semana${i}.md` });
289
+ removeFileIfExists(path.join("semanas", `demo_semana${i}.md`), {
290
+ label: path.join("semanas", `demo_semana${i}.md`),
291
+ });
292
+ }
293
+ }
294
+
295
+ console.log("\n┌──────────────────────────────────────────────┐");
296
+ console.log("│ Generador Open Class UNIMINUTO desde config │");
297
+ console.log("└──────────────────────────────────────────────┘\n");
298
+ console.log(`Configuración : ${configPath}`);
299
+ console.log(`Curso : ${courseName}`);
300
+ console.log(`Nombre corto : ${courseShort}`);
301
+ console.log(`Semanas : ${weeksTotal}`);
302
+ console.log(`Activas : ${activeWeeks.map((w) => w.number).join(", ") || "ninguna"}\n`);
303
+
304
+ ensureDir("semanas");
305
+ ensureProjectAssets();
306
+ cleanLegacyDemoFiles();
307
+
308
+ writeFileSafe("slides.md", buildPortal(), { overwrite: overwritePortal, label: "slides.md" });
309
+ writeFileSafe("scripts/decks.mjs", buildDecks(), { overwrite: overwriteDecks, label: "scripts/decks.mjs" });
310
+
311
+ for (const week of allWeeks) {
312
+ const slug = `${courseShort}_semana${week.number}`;
313
+ const tokens = weekTokens(week);
314
+ writeFileSafe(`${slug}.md`, replaceTokens(launcherTemplate, tokens), {
315
+ overwrite: overwriteLaunchers,
316
+ label: `${slug}.md`,
317
+ });
318
+ writeFileSafe(path.join("semanas", `${slug}.md`), replaceTokens(semanaTemplate, tokens), {
319
+ overwrite: overwriteWeekContent,
320
+ label: path.join("semanas", `${slug}.md`),
321
+ });
322
+ }
323
+
324
+ if (overwritePackageScripts && fs.existsSync("package.json")) {
325
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf-8"));
326
+ pkg.name = `openclass-${courseShort}`;
327
+ pkg.description = `Presentaciones Open Class del curso ${courseName}.`;
328
+ pkg.scripts = buildScripts();
329
+ writeFileSafe("package.json", JSON.stringify(pkg, null, 2) + "\n", { overwrite: true, label: "package.json" });
330
+ }
331
+
332
+ if (fs.existsSync("package-lock.json")) {
333
+ try {
334
+ const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf-8"));
335
+ lock.name = `openclass-${courseShort}`;
336
+ if (lock.packages && lock.packages[""]) {
337
+ lock.packages[""].name = `openclass-${courseShort}`;
338
+ }
339
+ writeFileSafe("package-lock.json", JSON.stringify(lock, null, 2) + "\n", { overwrite: true, label: "package-lock.json" });
340
+ } catch {
341
+ console.log("⚠️ package-lock.json no se actualizó porque no parece ser JSON válido.");
342
+ }
343
+ }
344
+
345
+ console.log("\n✅ Configuración generada.");
346
+ console.log(" Próximo paso sugerido: npm install");
347
+ console.log(" Vista de una semana: npm run dev:s1");
348
+ console.log(" Vista completa: npm run vista\n");