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
48 changes: 48 additions & 0 deletions docs/contenido.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,54 @@ prisma/content/courses/<course-slug>/
export const { allCourses, allPracticeSets } = buildContentRegistry(packages);
```

## Agrupar las unidades de un curso por semestre (`curriculum`)

Un `Course` puede tener 0, 1 o varias agrupaciones curriculares (p. ej. "1.er
semestre — Fundamentos de Desarrollo de Software"). Es organización, no identidad:
no crea rutas, no crea progreso ni XP propios, y `Unit.order` sigue siendo el único
orden real de navegación del curso — la agrupación sólo describe cómo se leen esas
unidades ya ordenadas.

`defineCourse` acepta exactamente uno de estos dos caminos (nunca ambos):

```ts
// CURSO GENERAL — sin agrupación curricular. Comportamiento de siempre.
defineCourse({
...metadata,
units: [unidadA, unidadB, /* ... */],
});

// CURSO CURRICULAR — unidades agrupadas por sección.
defineCourse({
...metadata,
curriculum: [
{
key: "s1-fundamentos-desarrollo-software", // identidad estable, NO va en URLs
semester: 1,
subjectName: "Fundamentos de Desarrollo de Software",
units: [unidadA, unidadB, unidadC],
},
{
key: "s2-programacion-estructurada",
semester: 2,
subjectName: "Programación Estructurada",
units: [unidadD, unidadE],
},
],
});
```

- El `order` de cada sección se deriva de su posición en el arreglo `curriculum`
(base 1) — no se declara a mano.
- `defineCourse` APLANA las unidades de todas las secciones, en el orden en que
aparecen, a `CourseDefinition.units`: ese aplanado es lo que el seed numera como
`Unit.order`, el orden real de navegación del curso.
- `practice` sigue viviendo colocalizada dentro de cada `AuthoredUnitDefinition`,
exactamente igual que en el camino sin `curriculum` — la agrupación curricular no
cambia en nada cómo se autora la práctica.
- Un curso sin `curriculum` es tan válido como uno con una o varias secciones:
`npm run content:validate` no lo exige.

- 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
Expand Down
123 changes: 107 additions & 16 deletions prisma/content/authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { validateContentRegistry } from "./validate";

import type {
CourseDefinition,
CurriculumSectionDefinition,
LessonDefinition,
UnitDefinition,
} from "./types";
Expand All @@ -45,6 +46,43 @@ export interface CoursePackageDefinition {
practiceSets: PracticeUnitSetDefinition[];
}

/**
* Una agrupación curricular tal como se autora: sus unidades EN LÍNEA (en
* vez de una lista de slugs apuntando a unidades declaradas aparte). El
* `order` de la sección se deriva de su posición en el arreglo
* `curriculum` — no se declara aquí.
*/
export interface AuthoredCurriculumSectionDefinition {
key: string;
semester: number;
subjectName: string;
units: AuthoredUnitDefinition[];
}

/** Metadata de curso sin `units` ni `curriculum` — común a ambos caminos. */
type CourseMetadata = Omit<CourseDefinition, "units" | "curriculum">;

/**
* Lo que acepta `defineCourse`. Exactamente uno de los dos caminos:
* - `units`: el curso NO tiene agrupación curricular (comportamiento de
* siempre, intacto).
* - `curriculum`: el curso se declara como secciones curriculares, cada
* una con sus unidades; `defineCourse` las aplana a `CourseDefinition.units`
* y deriva las `CurriculumSectionDefinition` correspondientes.
* No se acepta mezclar `units` sueltas con `curriculum` en el mismo curso.
*/
export type AuthoredCourseDefinition = CourseMetadata &
(
| {
units: AuthoredUnitDefinition[];
curriculum?: never;
}
| {
curriculum: AuthoredCurriculumSectionDefinition[];
units?: never;
}
);

/**
* 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
Expand All @@ -64,30 +102,86 @@ export function defineUnit(unit: AuthoredUnitDefinition): AuthoredUnitDefinition

/**
* 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.
* unidades autoradas (`units`) O secciones curriculares (`curriculum`),
* nunca ambas. 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 sections/units/lessons/steps/practice/
* tests recibido: no ordena alfabéticamente, no aplica defaults, no
* infiere `language` ni `executionProfile`, no muta el input.
*
* Preserva EXACTAMENTE el orden de units/lessons/steps/practice/tests
* recibido: no ordena alfabéticamente, no infiere `language` ni
* `executionProfile`, no muta el input.
* Camino `curriculum`: recorre las secciones en el orden recibido y,
* dentro de cada una, sus unidades en el orden recibido; aplana todas las
* unidades a `CourseDefinition.units` (ese aplanado es lo que define el
* orden GLOBAL de navegación del curso, vía `Unit.order` en el seed) y
* deriva `CurriculumSectionDefinition.order` (= posición de la sección +
* 1) y `unitSlugs` (de las unidades de esa sección).
*/
export function defineCourse(
course: Omit<CourseDefinition, "units"> & {
units: AuthoredUnitDefinition[];
},
course: AuthoredCourseDefinition,
): CoursePackageDefinition {
if (course.curriculum) {
return assembleFromCurriculum(course, course.curriculum);
}
return assembleFromUnits(course, course.units);
}

function assembleFromUnits(
metadata: CourseMetadata,
authoredUnits: AuthoredUnitDefinition[],
): CoursePackageDefinition {
const { units, practiceSets } = flattenUnits(metadata.slug, authoredUnits);
return {
course: { ...metadata, units },
practiceSets,
};
}

function assembleFromCurriculum(
metadata: CourseMetadata,
sections: AuthoredCurriculumSectionDefinition[],
): CoursePackageDefinition {
const units: UnitDefinition[] = [];
const practiceSets: PracticeUnitSetDefinition[] = [];
const curriculum: CurriculumSectionDefinition[] = [];

sections.forEach((section, sectionIndex) => {
const flattened = flattenUnits(metadata.slug, section.units);
units.push(...flattened.units);
practiceSets.push(...flattened.practiceSets);

curriculum.push({
key: section.key,
semester: section.semester,
subjectName: section.subjectName,
order: sectionIndex + 1,
unitSlugs: flattened.units.map((u) => u.slug),
});
});

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

/** Aplana unidades autoradas a `UnitDefinition[]` + sus `PracticeUnitSetDefinition[]`. */
function flattenUnits(
courseSlug: string,
authoredUnits: AuthoredUnitDefinition[],
): { units: UnitDefinition[]; practiceSets: PracticeUnitSetDefinition[] } {
const units: UnitDefinition[] = [];
const practiceSets: PracticeUnitSetDefinition[] = [];

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

if (practice) {
practiceSets.push({
courseSlug: course.slug,
courseSlug,
unitSlug: unit.slug,
unitTitle: unit.title,
unitIcon: unit.icon,
Expand All @@ -96,10 +190,7 @@ export function defineCourse(
}
}

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

/**
Expand Down
60 changes: 55 additions & 5 deletions prisma/content/courses/cpp-desde-cero/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
// 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.
// tal cual desde el `prisma/content/index.ts` anterior), el paso por
// `adaptLegacyUnits` para entrar a la capa de authoring, y la agrupación
// curricular EN DOS SEMESTRES (`curriculum`) que `defineCourse` aplana de
// vuelta a `CourseDefinition.units` — ese aplanado es el único orden real
// de navegación del curso (`Unit.order`, numerado por el seed).
// =====================================================================

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

import { unidad01 } from "../../unidad-01-primer-programa";
Expand Down Expand Up @@ -88,7 +91,54 @@ const legacyPracticeSets = [

const authoredUnits = adaptLegacyUnits(cursoCppLegacy, legacyPracticeSets);

const unitsBySlug = new Map(authoredUnits.map((u) => [u.slug, u] as const));

/** Selecciona una Unit legacy ya adaptada por su slug. Falla cerrado. */
function unit(slug: string): AuthoredUnitDefinition {
const found = unitsBySlug.get(slug);
if (!found) {
throw new Error(
`courses/cpp-desde-cero: no existe la unidad "${slug}" en cursoCppLegacy.units.`,
);
}
return found;
}

const { slug, title, description, subjectName, academicContext, language, executionProfile } =
cursoCppLegacy;

export const cppDesdeCero = defineCourse({
...cursoCppLegacy,
units: authoredUnits,
slug,
title,
description,
subjectName,
academicContext,
language,
executionProfile,
curriculum: [
{
key: "s1-fundamentos-desarrollo-software",
semester: 1,
subjectName: "Fundamentos de Desarrollo de Software",
units: [
unit("primer-programa"),
unit("variables-y-tipos"),
unit("leer-datos"),
unit("control-de-flujo"),
unit("loops"),
unit("printf-scanf"),
],
},
{
key: "s2-programacion-estructurada",
semester: 2,
subjectName: "Programación Estructurada",
units: [
unit("funciones"),
unit("arreglos"),
unit("archivos"),
unit("matrices"),
],
},
],
});
32 changes: 30 additions & 2 deletions prisma/content/courses/csharp-poo-1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
// editaron. Aquí sólo se reutilizan y se pasan por `adaptLegacyUnits` +
// `defineCourse` para entrar a la misma capa de authoring que los cursos
// nuevos.
//
// Segundo caso de `curriculum`, deliberadamente distinto de C++: UNA sola
// sección (3.er semestre) que envuelve las 8 unidades existentes tal
// cual, en su mismo orden. Demuestra que la agrupación curricular no está
// hardcodeada para C++ ni para dos semestres.
// =====================================================================

import { adaptLegacyUnits, defineCourse } from "../../authoring";
Expand All @@ -15,7 +20,30 @@ import { csharpPracticeSets } from "../../exercises/csharp";

const authoredUnits = adaptLegacyUnits(cursoCsharpPoo1, csharpPracticeSets);

const {
slug,
title,
description,
subjectName,
academicContext,
language,
executionProfile,
} = cursoCsharpPoo1;

export const csharpPoo1 = defineCourse({
...cursoCsharpPoo1,
units: authoredUnits,
slug,
title,
description,
subjectName,
academicContext,
language,
executionProfile,
curriculum: [
{
key: "s3-programacion-orientada-objetos-1",
semester: 3,
subjectName: "Programación Orientada a Objetos I",
units: authoredUnits,
},
],
});
30 changes: 30 additions & 0 deletions prisma/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ export interface CourseDefinition {
language: LanguageId;
executionProfile: ExecutionProfileId;
units: UnitDefinition[];
/**
* Agrupaciones curriculares OPCIONALES de las `units` de este curso (p.
* ej. "1.er semestre — Fundamentos de Desarrollo de Software"). Un curso
* sin `curriculum` sigue siendo válido: la agrupación es organización,
* no identidad — no crea rutas, no afecta progreso/XP y `Unit.order`
* sigue siendo el único orden real de navegación.
*/
curriculum?: CurriculumSectionDefinition[];
}

/**
* Una agrupación curricular (p. ej. una materia de un semestre) dentro de
* UN Course. Agrupa `Unit`s ya existentes del curso — no es una entidad de
* identidad propia: no aparece en URLs, no tiene progreso ni XP propios.
*/
export interface CurriculumSectionDefinition {
/** Identidad estable dentro del Course. No forma parte de URLs. */
key: string;

/** Entero positivo. NO limitar a 1..8. */
semester: number;

/** Nombre académico de la materia/bloque. */
subjectName: string;

/** Derivado del orden del array. Base 1. */
order: number;

/** Slugs de las Units pertenecientes a esta section. */
unitSlugs: string[];
}

export interface UnitDefinition {
Expand Down
Loading
Loading