Apuntes DAM
Volver al inicio

Generador de documentación: de JSDoc a Markdown, con avisos

Ejercicio de JavaScriptDifícilUnos 75 minutos

Programa un generador de documentación como JSDoc: encuentra las funciones de un fichero, lee su comentario /** */ con @param, @returns, @throws, @example y @deprecated, genera la referencia en Markdown y avisa de lo que falta: funciones sin documentar, parámetros olvidados o @returns que sobran.

  • Documentación de aplicaciones
  • Comentarios JSDoc y sus etiquetas
  • Generar Markdown
  • Expresiones regulares con grupos
  • Analizar código fuente
  • Avisos de calidad (como un linter)

Enunciado

La documentación técnica de una aplicación no se escribe aparte: se genera a partir de los comentarios del código. En Java es Javadoc; en JavaScript, JSDoc: un comentario /** ... */ justo encima de cada función con una descripción y etiquetas como @param {number} nota - La nota o @returns {boolean}. Herramientas como jsdoc o TypeDoc leen esos comentarios y producen la referencia, y los editores los usan para el autocompletado.

La entrada es un fichero JavaScript. El código de partida ya encuentra las funciones de primer nivel (declaradas con function o asignadas a una const), separa sus parámetros y sabe si devuelven un valor. Tú lees el comentario de cada una, generas su sección en Markdown y reúnes los avisos.

Qué tiene que hacer el programa

  1. El JSDoc de una función es el comentario /** ... */ pegado justo antes (solo con espacios en medio); si lo último antes es un comentario normal /* */ u otro código, no tiene. Las funciones cuyo nombre empieza por _ o con @private no aparecen. Las que no tienen JSDoc no se documentan: aviso nombre: sin documentar.
  2. Del comentario, sin el * de cada línea: la descripción son las líneas antes de la primera etiqueta, unidas con un espacio; cada etiqueta (@nombre texto) sigue en las líneas siguientes, unidas con un espacio, salvo @example, que conserva sus líneas. En un comentario de una sola línea, cada @etiqueta empieza una etiqueta nueva.
  3. La sección: una línea en blanco, ## nombre(params)` (los parámetros como en el código, con ... si lo llevan), y, separados por líneas en blanco: > **Obsoleta:** texto (o > **Obsoleta.**), la descripción, *Desde la versión X.*, la tabla **Parámetros** con | Nombre | Tipo | Descripción | y una fila por parámetro de la función (| p | tipo | texto |; uno opcional [p] o [p=v] añade *(opcional)* o *(opcional, por defecto v)*; uno sin documentar, | p | — | *sin documentar* |), **Devuelve** tipo: texto, **Lanza** tipo: texto por cada @throws y **Ejemplo** con el código en un bloque js. Los | dentro de la tabla, escapados (\|`).
  4. Avisos, por función y en este orden: @param mal escrito «texto» (sin {tipo} o sin nombre), @param «x» no existe en la función, el parámetro «x» no está documentado, devuelve un valor pero falta @returns (@return también vale), tiene @returns pero no devuelve nada (salvo con tipo void o undefined) y etiqueta desconocida @x (las conocidas: param, returns, return, throws, example, deprecated, private y since). Cada uno como - nombre: aviso.
  5. La salida empieza con # Documentación, sigue con las secciones y acaba con una línea en blanco, ## Avisos, otra en blanco y los avisos (o Ninguno.).

Entrada

Un fichero de código JavaScript.

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Funciones de notas

Entrada

/**
 * Calcula la nota media de una lista de notas.
 * Las notas vacías (null) no cuentan.
 * @param {number[]} notas - Las notas, de 0 a 10
 * @param {number} [decimales=2] - Decimales del resultado
 * @returns {number} La media redondeada
 * @throws {RangeError} Si alguna nota está fuera de 0..10
 * @example
 * media([5, 7, 9]);    // 7
 * media([5, 6], 0);    // 6
 */
export function media(notas, decimales = 2) {
  if (notas.some((n) => n < 0 || n > 10)) throw new RangeError("nota fuera de rango");
  const validas = notas.filter((n) => n !== null);
  return Number((validas.reduce((a, b) => a + b, 0) / validas.length).toFixed(decimales));
}

/**
 * Dice si una nota es un aprobado.
 * @param {number} nota
 * @returns {boolean}
 */
const aprobado = (nota) => nota >= 5;

/**
 * Pone la nota en letra.
 * @param {number} nota - La nota numérica
 * @param {string} idioma - Idioma de la etiqueta
 * @deprecated Usa `calificar()`, que admite la matrícula de honor.
 * @autor Ana
 */
function enLetra(nota, escala) {
  if (nota < 5) return "Suspenso";
  if (nota < 7) return "Aprobado";
  return nota < 9 ? "Notable" : "Sobresaliente";
}

function sinDocumentar(a, b) {
  return a * b;
}

/**
 * Ayuda interna.
 * @private
 */
function redondear(x) {
  return Math.round(x);
}

/**
 * Muestra la media por consola.
 * @param {number[]} notas - Las notas
 * @returns {void}
 */
function mostrar(notas) {
  console.log(media(notas));
}

Salida por consola

# Documentación

## `media(notas, decimales)`

Calcula la nota media de una lista de notas. Las notas vacías (null) no cuentan.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `notas` | `number[]` | Las notas, de 0 a 10 |
| `decimales` | `number` | Decimales del resultado *(opcional, por defecto `2`)* |

**Devuelve** `number`: La media redondeada

**Lanza** `RangeError`: Si alguna nota está fuera de 0..10

**Ejemplo**

```js
media([5, 7, 9]);    // 7
media([5, 6], 0);    // 6
```

## `aprobado(nota)`

Dice si una nota es un aprobado.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `nota` | `number` |  |

**Devuelve** `boolean`

## `enLetra(nota, escala)`

> **Obsoleta:** Usa `calificar()`, que admite la matrícula de honor.

Pone la nota en letra.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `nota` | `number` | La nota numérica |
| `escala` | — | *sin documentar* |

## `mostrar(notas)`

Muestra la media por consola.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `notas` | `number[]` | Las notas |

**Devuelve** `void`

## Avisos

- enLetra: @param «idioma» no existe en la función
- enLetra: el parámetro «escala» no está documentado
- enLetra: devuelve un valor pero falta @returns
- enLetra: etiqueta desconocida @autor
- sinDocumentar: sin documentar

Flechas, async y comentarios normales

Entrada

// Utilidades de fechas (este comentario no es JSDoc)
function hoy() {
  return new Date();
}

/**
 * Suma días a una fecha.
 * @since 1.2.0
 * @param {Date} fecha - Fecha de partida
 * @param {number} dias - Días a sumar (pueden ser negativos)
 * @return {Date} Una fecha nueva; la original no cambia
 */
export const sumarDias = (fecha, dias) => new Date(fecha.getTime() + dias * 86400000);

/**
 * Máximo de varios números.
 * @param {...number} numeros - Los números
 * @returns {number} El mayor, o -Infinity si no hay ninguno
 */
const maximo = function (numeros) {
  return Math.max(...numeros);
};

/**
 * Descarga el calendario escolar.
 * @param {string|URL} url - Dirección del JSON
 * @param {object} [opciones] - Opciones de la descarga
 * @returns {Promise<object[]>} Los días festivos
 * @throws {TypeError} Si la URL no es válida
 * @throws {Error} Si el servidor responde con un error
 */
export async function descargar(url, opciones = { reintentos: 3 }) {
  const r = await fetch(url);
  if (!r.ok) throw new Error(r.statusText);
  return r.json();
}

/**
 * Registra un mensaje.
 * @param {string} texto - El mensaje
 * @returns {string} El mensaje registrado
 */
const log = texto => {
  console.log("{" + texto + "}");
};

/* Un comentario normal entre el JSDoc y la función */
/**
 * Esto no documenta nada: hay código en medio.
 */
let contador = 0;
function siguiente() {
  return ++contador;
}

Salida por consola

# Documentación

## `sumarDias(fecha, dias)`

Suma días a una fecha.

*Desde la versión 1.2.0.*

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `fecha` | `Date` | Fecha de partida |
| `dias` | `number` | Días a sumar (pueden ser negativos) |

**Devuelve** `Date`: Una fecha nueva; la original no cambia

## `maximo(numeros)`

Máximo de varios números.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `numeros` | `...number` | Los números |

**Devuelve** `number`: El mayor, o -Infinity si no hay ninguno

## `descargar(url, opciones)`

Descarga el calendario escolar.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `url` | `string\|URL` | Dirección del JSON |
| `opciones` | `object` | Opciones de la descarga *(opcional)* |

**Devuelve** `Promise<object[]>`: Los días festivos

**Lanza** `TypeError`: Si la URL no es válida

**Lanza** `Error`: Si el servidor responde con un error

## `log(texto)`

Registra un mensaje.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `texto` | `string` | El mensaje |

**Devuelve** `string`: El mensaje registrado

## Avisos

- hoy: sin documentar
- log: tiene @returns pero no devuelve nada
- siguiente: sin documentar

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. ¿Hay un JSDoc pegado?

Corta el código hasta la función, quita los espacios del final (trimEnd) y mira si acaba en */. El comentario empieza en el último /**, pero solo si después no empieza otro /*.

javascript
const antes = fuente.slice(0, posicion).trimEnd();
if (!antes.endsWith("*/")) return null;
const inicio = antes.lastIndexOf("/**");
2. Limpiar las líneas

replace(/^\s*\* ?/, "") quita la sangría y el asterisco de cada línea. Después, una línea que empieza por @ abre una etiqueta y las demás se añaden a la última abierta.

3. Analizar @param

Una expresión regular con tres grupos: el tipo entre llaves, el nombre (que puede ir entre corchetes si es opcional) y la descripción, con un guion opcional delante.

4. Comparar con el código

Los avisos salen de comparar lo documentado con lo real: los nombres de los parámetros (sin ...) y si la función devuelve algo.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🟨JavaScriptGenerador de documentación: de JSDoc a Markdown, con avisosDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
/**
 * Calcula la nota media de una lista de notas.
 * Las notas vacías (null) no cuentan.
 * @param {number[]} notas - Las notas, de 0 a 10
 * @param {number} [decimales=2] - Decimales del resultado
 * @returns {number} La media redondeada
 * @throws {RangeError} Si alguna nota está fuera de 0..10
 * @example
 * media([5, 7, 9]);    // 7
 * media([5, 6], 0);    // 6
 */
export function media(notas, decimales = 2) {
  if (notas.some((n) => n < 0 || n > 10)) throw new RangeError("nota fuera de rango");
  const validas = notas.filter((n) => n !== null);
  return Number((validas.reduce((a, b) => a + b, 0) / validas.length).toFixed(decimales));
}

/**
 * Dice si una nota es un aprobado.
 * @param {number} nota
 * @returns {boolean}
 */
const aprobado = (nota) => nota >= 5;

/**
 * Pone la nota en letra.
 * @param {number} nota - La nota numérica
 * @param {string} idioma - Idioma de la etiqueta
 * @deprecated Usa `calificar()`, que admite la matrícula de honor.
 * @autor Ana
 */
function enLetra(nota, escala) {
  if (nota < 5) return "Suspenso";
  if (nota < 7) return "Aprobado";
  return nota < 9 ? "Notable" : "Sobresaliente";
}

function sinDocumentar(a, b) {
  return a * b;
}

/**
 * Ayuda interna.
 * @private
 */
function redondear(x) {
  return Math.round(x);
}

/**
 * Muestra la media por consola.
 * @param {number[]} notas - Las notas
 * @returns {void}
 */
function mostrar(notas) {
  console.log(media(notas));
}
Salida esperada
# Documentación

## `media(notas, decimales)`

Calcula la nota media de una lista de notas. Las notas vacías (null) no cuentan.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `notas` | `number[]` | Las notas, de 0 a 10 |
| `decimales` | `number` | Decimales del resultado *(opcional, por defecto `2`)* |

**Devuelve** `number`: La media redondeada

**Lanza** `RangeError`: Si alguna nota está fuera de 0..10

**Ejemplo**

```js
media([5, 7, 9]);    // 7
media([5, 6], 0);    // 6
```

## `aprobado(nota)`

Dice si una nota es un aprobado.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `nota` | `number` |  |

**Devuelve** `boolean`

## `enLetra(nota, escala)`

> **Obsoleta:** Usa `calificar()`, que admite la matrícula de honor.

Pone la nota en letra.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `nota` | `number` | La nota numérica |
| `escala` | — | *sin documentar* |

## `mostrar(notas)`

Muestra la media por consola.

**Parámetros**

| Nombre | Tipo | Descripción |
| --- | --- | --- |
| `notas` | `number[]` | Las notas |

**Devuelve** `void`

## Avisos

- enLetra: @param «idioma» no existe en la función
- enLetra: el parámetro «escala» no está documentado
- enLetra: devuelve un valor pero falta @returns
- enLetra: etiqueta desconocida @autor
- sinDocumentar: sin documentar
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
javascript
1const fuente = require("fs").readFileSync(0, "utf8");
2
3const ETIQUETAS = ["param", "returns", "return", "throws", "example", "deprecated", "private", "since"];
4
5// Funciones de primer nivel (empiezan en la columna 0): declaradas o asignadas a una constante
6const RE_FUNCION = /^(?:export\s+)?(?:async\s+)?function\s*([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/gm;
7const RE_CONSTANTE = /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\s*\(([^)]*)\)|\(([^)]*)\)\s*=>|([A-Za-z_$][\w$]*)\s*=>)/gm;
8
9/** Todas las funciones, en el orden del código: { nombre, params, inicio, fin, flecha }. */
10function buscarFunciones() {
11  const r = [];
12  for (const m of fuente.matchAll(RE_FUNCION)) r.push({ nombre: m[1], params: m[2], inicio: m.index, fin: m.index + m[0].length, flecha: false });
13  for (const m of fuente.matchAll(RE_CONSTANTE)) {
14    r.push({ nombre: m[1], params: m[2] ?? m[3] ?? m[4], inicio: m.index, fin: m.index + m[0].length, flecha: m[2] === undefined });
15  }
16  return r.sort((a, b) => a.inicio - b.inicio);
17}
18
19/** "a, b = 2, ...resto" → ["a", "b", "...resto"] */
20const nombresParametros = (texto) => texto.split(",").map((p) => p.split("=")[0].trim()).filter(Boolean);
21
22/** El cuerpo entre llaves que empieza en la primera { desde la posición dada (sin contar las llaves de los textos). */
23function cuerpo(desde) {
24  const inicio = fuente.indexOf("{", desde);
25  if (inicio < 0) return "";
26  let nivel = 0, comilla = null;
27  for (let i = inicio; i < fuente.length; i++) {
28    const c = fuente[i];
29    if (comilla) {
30      if (c === "\\") i++;
31      else if (c === comilla) comilla = null;
32    } else if (c === '"' || c === "'" || c === "`") comilla = c;
33    else if (c === "{") nivel++;
34    else if (c === "}" && --nivel === 0) return fuente.slice(inicio, i + 1);
35  }
36  return fuente.slice(inicio);
37}
38
39/** ¿Devuelve un valor? Una flecha sin llaves devuelve su expresión; si no, hace falta un return con algo. */
40function devuelveValor(f) {
41  if (f.flecha && !/^\s*\{/.test(fuente.slice(f.fin))) return true;
42  return /\breturn\s*[^;\s}]/.test(cuerpo(f.fin));
43}
44
45/** El comentario /** ... *\/ pegado justo antes de la posición, o null. */
46function jsdocAntes(posicion) {
47  const antes = fuente.slice(0, posicion).trimEnd();
48  if (!antes.endsWith("*/")) return null;
49  const inicio = antes.lastIndexOf("/**");
50  // si después del /** empieza otro comentario normal, el que va pegado no es JSDoc
51  if (inicio < 0 || antes.lastIndexOf("/*") !== inicio) return null;
52  return antes.slice(inicio + 3, -2);
53}
54
55/** Descripción y etiquetas: [{ nombre, texto }]; el texto de @example conserva los saltos de línea. */
56function analizar(comentario) {
57  let filas = comentario.split("\n").map((l) => l.replace(/^\s*\* ?/, "").trimEnd());
58  // en un comentario de una línea las etiquetas van seguidas: /** Texto. @returns {string} */
59  if (filas.length === 1) filas = filas[0].split(/\s+(?=@[a-z]+\b)/);
60  const descripcion = [], etiquetas = [];
61  for (const fila of filas) {
62    const m = /^@(\w+)\s?(.*)$/.exec(fila.trim());
63    if (m) etiquetas.push({ nombre: m[1], filas: [m[2]] });
64    else if (etiquetas.length) etiquetas[etiquetas.length - 1].filas.push(fila);
65    else descripcion.push(fila.trim());
66  }
67  return {
68    descripcion: descripcion.filter(Boolean).join(" "),
69    etiquetas: etiquetas.map((e) => ({
70      nombre: e.nombre,
71      texto: e.nombre === "example" ? e.filas.join("\n").replace(/^\n+|\n+$/g, "") : e.filas.map((f) => f.trim()).filter(Boolean).join(" "),
72    })),
73  };
74}
75
76const celda = (s) => s.replace(/\|/g, "\\|");
77
78function documentar(f, doc, avisos) {
79  const params = nombresParametros(f.params);
80  const limpios = params.map((p) => p.replace(/^\.\.\./, ""));
81  const md = ["", `## \`${f.nombre}(${params.join(", ")})\``, ""];
82  const etiqueta = (n) => doc.etiquetas.filter((e) => e.nombre === n);
83  const obsoleta = etiqueta("deprecated")[0];
84  if (obsoleta) md.push(obsoleta.texto ? `> **Obsoleta:** ${obsoleta.texto}` : "> **Obsoleta.**", "");
85  if (doc.descripcion) md.push(doc.descripcion, "");
86  const desde = etiqueta("since")[0];
87  if (desde) md.push(`*Desde la versión ${desde.texto}.*`, "");
88
89  const documentados = {};
90  for (const e of etiqueta("param")) {
91    const m = /^\{([^}]+)\}\s+(\[[^\]]+\]|[\w$.]+)\s*(?:-\s*)?(.*)$/.exec(e.texto);
92    if (!m) {
93      avisos.push(`${f.nombre}: @param mal escrito «${e.texto}»`);
94      continue;
95    }
96    let nombre = m[2], extra = "";
97    if (nombre.startsWith("[")) {
98      const [n, defecto] = nombre.slice(1, -1).split("=");
99      nombre = n.trim();
100      extra = defecto === undefined ? " *(opcional)*" : ` *(opcional, por defecto \`${defecto.trim()}\`)*`;
101    }
102    if (!limpios.includes(nombre)) avisos.push(`${f.nombre}: @param «${nombre}» no existe en la función`);
103    else documentados[nombre] = { tipo: m[1], texto: m[3] + extra };
104  }
105  if (limpios.length) {
106    md.push("**Parámetros**", "", "| Nombre | Tipo | Descripción |", "| --- | --- | --- |");
107    for (const p of limpios) {
108      const d = documentados[p];
109      if (!d) avisos.push(`${f.nombre}: el parámetro «${p}» no está documentado`);
110      md.push(d ? `| \`${p}\` | \`${celda(d.tipo)}\` | ${celda(d.texto.trim())} |` : `| \`${p}\` | — | *sin documentar* |`);
111    }
112    md.push("");
113  }
114
115  const devuelve = [...etiqueta("returns"), ...etiqueta("return")][0];
116  const valor = devuelveValor(f);
117  if (devuelve) {
118    const m = /^(?:\{([^}]+)\})?\s*(?:-\s*)?(.*)$/.exec(devuelve.texto);
119    md.push(`**Devuelve**${m[1] ? ` \`${m[1]}\`` : ""}${m[2] ? `: ${m[2]}` : ""}`, "");
120    if (!valor && !/^(void|undefined)$/.test(m[1] ?? "")) avisos.push(`${f.nombre}: tiene @returns pero no devuelve nada`);
121  } else if (valor) {
122    avisos.push(`${f.nombre}: devuelve un valor pero falta @returns`);
123  }
124  for (const e of etiqueta("throws")) {
125    const m = /^(?:\{([^}]+)\})?\s*(?:-\s*)?(.*)$/.exec(e.texto);
126    md.push(`**Lanza**${m[1] ? ` \`${m[1]}\`` : ""}${m[2] ? `: ${m[2]}` : ""}`, "");
127  }
128  for (const e of etiqueta("example")) md.push("**Ejemplo**", "", "```js", e.texto, "```", "");
129  for (const e of doc.etiquetas) {
130    if (!ETIQUETAS.includes(e.nombre)) avisos.push(`${f.nombre}: etiqueta desconocida @${e.nombre}`);
131  }
132  while (md[md.length - 1] === "") md.pop();
133  return md;
134}
135
136function main() {
137  const salida = ["# Documentación"];
138  const avisos = [];
139  for (const f of buscarFunciones()) {
140    const comentario = jsdocAntes(f.inicio);
141    const doc = comentario === null ? null : analizar(comentario);
142    if (f.nombre.startsWith("_") || doc?.etiquetas.some((e) => e.nombre === "private")) continue;
143    if (!doc) {
144      avisos.push(`${f.nombre}: sin documentar`);
145      continue;
146    }
147    salida.push(...documentar(f, doc, avisos));
148  }
149  salida.push("", "## Avisos", "");
150  if (avisos.length === 0) salida.push("Ninguno.");
151  for (const a of avisos) salida.push(`- ${a}`);
152  console.log(salida.join("\n"));
153}
154
155main();

Generar la documentación desde el código la mantiene al día: está al lado de lo que describe y quien cambia la función ve el comentario. Lo que se documenta en un fichero aparte se queda viejo en la primera modificación.

Los avisos son lo que hacen eslint-plugin-jsdoc o el propio Javadoc con -Xdoclint: un parámetro renombrado en el código pero no en el comentario es un error de documentación tan real como uno de compilación, y en un proyecto con integración continua puede hacer fallar el build.

Este analizador usa expresiones regulares y funciona con código ordenado, pero tiene límites (un valor por defecto con comas, funciones dentro de funciones). Las herramientas reales construyen el árbol sintáctico (AST) con un parser como Acorn o el compilador de TypeScript.

@deprecated es la forma correcta de retirar una función: sigue funcionando, pero la documentación y el editor avisan de que no se use y dicen qué usar en su lugar.

Para ir más allá

  • Añade un índice al principio con un enlace a cada función.
  • Documenta también las clases y sus métodos (class X { metodo() {} }).
  • Comprueba que los ejemplos funcionan: ejecuta cada línea de @example y compara con el comentario // resultado.

Dónde se explica