Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions docs/contenido.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,77 @@ a la base.

```
prisma/content/
├── types.ts # la forma de todo lo de abajo
├── index.ts # registro de CURSOS
├── unidad-01-primer-programa.ts … unidad-10-matrices.ts # curso de C++
├── csharp/ # curso de POO I en C#
│ ├── index.ts # el curso y su lenguaje/perfil
├── types.ts # la forma de una lección/unidad/curso (IR)
├── authoring.ts # defineLesson / defineUnit / defineCourse / registry
├── validate.ts # validación semántica (sin DB, sin compiladores)
├── index.ts # re-exports legacy (cursoCpp, allCourses)
├── courses/
│ ├── index.ts # EL REGISTRY ÚNICO — un curso nuevo se agrega aquí
│ ├── cpp-desde-cero/index.ts # ensambla el curso legacy de C++ (ver abajo)
│ └── csharp-poo-1/index.ts # ensambla el curso legacy de C#
├── unidad-01-primer-programa.ts … unidad-10-matrices.ts # curso de C++ (legacy)
├── csharp/ # curso de POO I en C# (legacy)
│ ├── index.ts
│ └── unidad-01-modelar.ts … unidad-08-integrador.ts
└── exercises/ # práctica por unidad
├── u01-…-u10-… # banco de C++
└── csharp/ # banco de C#
├── index.ts # re-export legacy (allPracticeSets)
├── u01-…-u10-… # banco de C++ (legacy)
└── csharp/ # banco de C# (legacy)
```

C++ y C# son contenido **legacy**: sus unidades y su práctica viven en los archivos
grandes de siempre y NO se movieron. `prisma/content/courses/cpp-desde-cero/index.ts` y
`.../csharp-poo-1/index.ts` sólo los ENSAMBLAN con `adaptLegacyUnits` + `defineCourse`
para que entren al mismo registry que un curso nuevo. No repitas ese layout para
contenido viejo — es sólo el punto de entrada.

## Agregar un curso NUEVO

Un curso nuevo SÍ usa el layout completo, con la práctica de cada unidad colocalizada
junto a sus lecciones:

```
prisma/content/courses/<course-slug>/
├── index.ts # defineCourse({...metadata, units})
└── units/
└── 01-<unit-name>/
├── index.ts # defineUnit({...metadata, lessons, practice})
├── practice.ts # PracticeExerciseDefinition[] de la unidad
└── lessons/
├── 01-<lesson>.ts # defineLesson({...})
├── 02-<lesson>.ts
└── ...
```

- `defineLesson` y `defineUnit` son identidad type-safe: no aplican defaults, no
reordenan, no mutan lo que les pasas — sólo ayudan a que TypeScript infiera el tipo
correcto.
- `practice` vive DENTRO de `AuthoredUnitDefinition` (colocalizada con la unidad, en vez
de un registry aparte que hay que mantener sincronizado a mano). `defineCourse` la
separa en su propio `PracticeUnitSetDefinition`, derivando `courseSlug`, `unitSlug`,
`unitTitle` e `unitIcon` de la unidad — no los repitas.
- Registra el curso UNA sola vez, en el orden en que debe aparecer, en
[`prisma/content/courses/index.ts`](../prisma/content/courses/index.ts):

```ts
import { cppDesdeCero } from "./cpp-desde-cero";
import { csharpPoo1 } from "./csharp-poo-1";
import { miCursoNuevo } from "./mi-curso-nuevo";

const packages = [cppDesdeCero, csharpPoo1, miCursoNuevo] satisfies
readonly CoursePackageDefinition[];

export const { allCourses, allPracticeSets } = buildContentRegistry(packages);
```

- Antes de sembrar, corre `npm run content:validate`: valida TODO el contenido (slugs
únicos, quiz/fill_blank/code_challenge bien formados, referencias de práctica, el par
lenguaje/perfil, etc.) sin tocar la base ni compilar código. Si algo falla, imprime
CADA problema con su `path` (ej. `courses[mi-curso].units[u1].lessons[l1].steps[3]`) y
sale con código 1 — corre esto en vez de intentar depurar un `db:seed` a medias.

No hace falta migrar el contenido viejo a este layout: C++ y C# se quedan como están.

## El curso declara su lenguaje

Cada `CourseDefinition` trae `language` y `executionProfile`. **De ahí sale todo**:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "dotenv -e .env.local -- vitest run --config vitest.integration.config.ts",
"content:validate": "tsx scripts/validate-content.ts",
"postinstall": "prisma generate",
"db:generate": "prisma generate",
"db:push": "dotenv -e .env.local -- prisma db push",
Expand Down
185 changes: 185 additions & 0 deletions prisma/content/authoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// =====================================================================
// Capa de authoring — composición type-safe sobre el IR existente
// (`CourseDefinition` / `PracticeUnitSetDefinition`, sin cambios).
//
// No introduce un formato nuevo: sigue siendo TypeScript. Lo que da es
// una forma de escribir un curso NUEVO con su práctica colocalizada por
// unidad (`AuthoredUnitDefinition.practice`) y un único punto donde se
// ensamblan todos los cursos (`buildContentRegistry`), en vez de mantener
// a mano dos arreglos paralelos (`units` y `practiceSets`) que se pueden
// desincronizar.
//
// `defineLesson` / `defineUnit` son identidad: no aplican defaults, no
// reordenan, no mutan. Los defaults (xpReward, published, etc.) siguen
// viviendo exclusivamente en los seeds — ver `prisma/seed-content.ts` y
// `prisma/seed-practice.ts`.
// =====================================================================

import { validateContentRegistry } from "./validate";

import type {
CourseDefinition,
LessonDefinition,
UnitDefinition,
} from "./types";
import type {
PracticeExerciseDefinition,
PracticeUnitSetDefinition,
} from "./exercises/types";

/**
* Una unidad tal como se autora: sus lecciones, más su práctica
* colocalizada (si tiene). `practice` NUNCA llega a la DB como parte de
* la unidad — `defineCourse` lo separa en su propio
* `PracticeUnitSetDefinition`.
*/
export interface AuthoredUnitDefinition
extends Omit<UnitDefinition, "lessons"> {
lessons: LessonDefinition[];
practice?: PracticeExerciseDefinition[];
}

/** Lo que produce `defineCourse`: el curso (IR de siempre) + su práctica. */
export interface CoursePackageDefinition {
course: CourseDefinition;
practiceSets: PracticeUnitSetDefinition[];
}

/**
* Identidad type-safe. No aplica defaults, no clona ni reordena — sólo
* ayuda a que TypeScript infiera el tipo correcto en el sitio donde se
* declara la lección.
*/
export function defineLesson(lesson: LessonDefinition): LessonDefinition {
return lesson;
}

/**
* Identidad type-safe para unidades autoradas. Preserva metadata, lessons
* y practice tal cual se pasaron — no aplica defaults ni ordena nada.
*/
export function defineUnit(unit: AuthoredUnitDefinition): AuthoredUnitDefinition {
return unit;
}

/**
* Ensambla un `CoursePackageDefinition` a partir de metadata de curso +
* unidades autoradas. Separa la práctica colocalizada de cada unidad en
* su propio `PracticeUnitSetDefinition`, derivando `courseSlug`,
* `unitSlug`, `unitTitle` y `unitIcon` de la unidad — nunca se infieren
* de otro lado ni se piden por duplicado.
*
* Preserva EXACTAMENTE el orden de units/lessons/steps/practice/tests
* recibido: no ordena alfabéticamente, no infiere `language` ni
* `executionProfile`, no muta el input.
*/
export function defineCourse(
course: Omit<CourseDefinition, "units"> & {
units: AuthoredUnitDefinition[];
},
): CoursePackageDefinition {
const units: UnitDefinition[] = [];
const practiceSets: PracticeUnitSetDefinition[] = [];

for (const authoredUnit of course.units) {
const { practice, ...unit } = authoredUnit;
units.push(unit);

if (practice) {
practiceSets.push({
courseSlug: course.slug,
unitSlug: unit.slug,
unitTitle: unit.title,
unitIcon: unit.icon,
exercises: practice,
});
}
}

return {
course: { ...course, units },
practiceSets,
};
}

/**
* Adapta un curso legacy (unidades grandes en un solo archivo, práctica
* en un registry aparte por `unitSlug`) a la misma capa de authoring que
* usan los cursos nuevos, SIN mover ni tocar su contenido.
*
* Preserva el orden de `course.units`. Empareja cada set de práctica con
* su unidad por `unitSlug` y falla (Error, no `ContentValidationError`:
* esto es un error de ENSAMBLAJE, detectado antes de que exista un
* registry que validar) si:
* - un set declara un `courseSlug` que no es el de `course`;
* - un set apunta a una unidad que no existe en `course`;
* - hay más de un set para la misma unidad.
*
* `unitTitle`/`unitIcon` del set legacy son metadata duplicada antigua:
* NO se comparan contra la unidad. El IR canónico que sale de
* `defineCourse` siempre deriva esos campos de `unit.title`/`unit.icon`.
*/
export function adaptLegacyUnits(
course: CourseDefinition,
practiceSets: readonly PracticeUnitSetDefinition[],
): AuthoredUnitDefinition[] {
const practiceByUnitSlug = new Map<string, PracticeUnitSetDefinition>();

for (const set of practiceSets) {
if (set.courseSlug !== course.slug) {
throw new Error(
`adaptLegacyUnits: el set de práctica de la unidad "${set.unitSlug}" ` +
`declara courseSlug "${set.courseSlug}", pero se está adaptando ` +
`el curso "${course.slug}".`,
);
}

const unitExists = course.units.some((u) => u.slug === set.unitSlug);
if (!unitExists) {
throw new Error(
`adaptLegacyUnits: el set de práctica declara la unidad ` +
`"${set.unitSlug}", que no existe en el curso "${course.slug}".`,
);
}

if (practiceByUnitSlug.has(set.unitSlug)) {
throw new Error(
`adaptLegacyUnits: hay más de un set de práctica para la unidad ` +
`"${set.unitSlug}" del curso "${course.slug}".`,
);
}

practiceByUnitSlug.set(set.unitSlug, set);
}

return course.units.map((unit): AuthoredUnitDefinition => {
const set = practiceByUnitSlug.get(unit.slug);
return set ? { ...unit, practice: set.exercises } : unit;
});
}

/**
* Aplana una lista de paquetes de curso en el registry canónico
* (`allCourses` + `allPracticeSets`), preservando el orden del arreglo
* `packages` y, dentro de cada curso, el orden curso→unidad de su
* práctica. Síncrona, no lee filesystem, no toca DB, no compila nada.
*
* Corre la validación semántica completa (`validateContentRegistry`)
* antes de devolver el registry: un `ContentValidationError` aquí
* significa que ALGO del contenido (de cualquier curso) es inválido, y
* el import de `./courses` falla con ese error en vez de dejar pasar un
* registry a medias.
*/
export function buildContentRegistry(
packages: readonly CoursePackageDefinition[],
): {
allCourses: CourseDefinition[];
allPracticeSets: PracticeUnitSetDefinition[];
} {
const allCourses = packages.map((p) => p.course);
const allPracticeSets = packages.flatMap((p) => p.practiceSets);

validateContentRegistry(allCourses, allPracticeSets);

return { allCourses, allPracticeSets };
}
94 changes: 94 additions & 0 deletions prisma/content/courses/cpp-desde-cero/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// =====================================================================
// Paquete de curso: C++ desde cero.
//
// Legacy: las 10 unidades y los 10 sets de práctica siguen viviendo en
// sus archivos grandes de siempre (`prisma/content/unidad-*.ts` y
// `prisma/content/exercises/u*.ts`) — NO se movieron ni se editaron. Lo
// único que vive aquí es el ENSAMBLAJE: la metadata del curso (relocada
// tal cual desde el `prisma/content/index.ts` anterior) y el paso por
// `adaptLegacyUnits` + `defineCourse` para entrar a la misma capa de
// authoring que usan los cursos nuevos.
// =====================================================================

import { adaptLegacyUnits, defineCourse } from "../../authoring";
import type { CourseDefinition } from "../../types";

import { unidad01 } from "../../unidad-01-primer-programa";
import { unidadCin } from "../../unidad-02-cin";
import { unidadVariables } from "../../unidad-03-variables";
import { unidad04 } from "../../unidad-04-control-flujo";
import { unidad05 } from "../../unidad-05-loops";
import { unidad06 } from "../../unidad-06-funciones";
import { unidad07 } from "../../unidad-07-printf-scanf";
import { unidad08 } from "../../unidad-08-arreglos";
import { unidad09 } from "../../unidad-09-archivos";
import { unidad10 } from "../../unidad-10-matrices";

import { u01PrimerProgramaExercises } from "../../exercises/u01-primer-programa";
import { u02CinExercises } from "../../exercises/u02-cin";
import { u03VariablesExercises } from "../../exercises/u03-variables";
import { u04ControlFlujoExercises } from "../../exercises/u04-control-flujo";
import { u05LoopsExercises } from "../../exercises/u05-loops";
import { u06FuncionesExercises } from "../../exercises/u06-funciones";
import { u07PrintfScanfExercises } from "../../exercises/u07-printf-scanf";
import { u08ArreglosExercises } from "../../exercises/u08-arreglos";
import { u09ArchivosExercises } from "../../exercises/u09-archivos";
import { u10MatricesExercises } from "../../exercises/u10-matrices";

const cursoCppLegacy: CourseDefinition = {
// El slug es identidad histórica: NO se renombra. Todas las URLs viejas,
// el progreso y los intentos de los alumnos cuelgan de él.
slug: "cpp-desde-cero",
title: "C++ desde cero",
description:
"El curso completo de C++ pensado para estudiantes del CETI Guadalajara. " +
"Cada concepto va seguido de práctica inmediata.",
subjectName: "Programación en C++",
academicContext: "Curso introductorio CETI",
language: "cpp",
executionProfile: "cpp17-wandbox",
// El ORDEN de este arreglo es el orden del curso (el seed numera por
// posición). "Variables y tipos" va ANTES de "Leer datos con cin": la
// unidad de `cin` ya usaba `int`, `double`, `string`, aritmética y
// `setprecision`, es decir, exactamente lo que la de variables enseña.
// Los slugs NO cambian —`leer-datos` y `variables-y-tipos` siguen siendo
// los mismos recursos, con el mismo progreso y los mismos enlaces—; lo
// único que cambia es en qué posición aparecen.
units: [
unidad01,
unidadVariables,
unidadCin,
unidad04,
unidad05,
unidad06,
unidad07,
unidad08,
unidad09,
unidad10,
],
};

// Orden histórico del registry de práctica (`prisma/content/exercises/index.ts`
// de antes de este refactor): por archivo, NO por posición de unidad en el
// curso. `adaptLegacyUnits` empareja por `unitSlug`, así que este arreglo
// sólo necesita traer los 10 sets — el orden de SALIDA de la práctica lo fija
// `defineCourse` recorriendo `course.units` (ver `authoring.ts`).
const legacyPracticeSets = [
u01PrimerProgramaExercises,
u02CinExercises,
u03VariablesExercises,
u04ControlFlujoExercises,
u05LoopsExercises,
u06FuncionesExercises,
u07PrintfScanfExercises,
u08ArreglosExercises,
u09ArchivosExercises,
u10MatricesExercises,
];

const authoredUnits = adaptLegacyUnits(cursoCppLegacy, legacyPracticeSets);

export const cppDesdeCero = defineCourse({
...cursoCppLegacy,
units: authoredUnits,
});
21 changes: 21 additions & 0 deletions prisma/content/courses/csharp-poo-1/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// =====================================================================
// Paquete de curso: Programación Orientada a Objetos I con C#.
//
// Legacy: las 8 unidades y los 8 sets de práctica siguen viviendo en
// `prisma/content/csharp/unidad-*.ts` y
// `prisma/content/exercises/csharp/u*.ts` — NO se movieron ni se
// editaron. Aquí sólo se reutilizan y se pasan por `adaptLegacyUnits` +
// `defineCourse` para entrar a la misma capa de authoring que los cursos
// nuevos.
// =====================================================================

import { adaptLegacyUnits, defineCourse } from "../../authoring";
import { cursoCsharpPoo1 } from "../../csharp";
import { csharpPracticeSets } from "../../exercises/csharp";

const authoredUnits = adaptLegacyUnits(cursoCsharpPoo1, csharpPracticeSets);

export const csharpPoo1 = defineCourse({
...cursoCsharpPoo1,
units: authoredUnits,
});
Loading
Loading