Generador de documentación: de JSDoc a Markdown, con avisos
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
- 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@privateno aparecen. Las que no tienen JSDoc no se documentan: avisonombre: sin documentar. - 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@etiquetaempieza una etiqueta nueva. - 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 defectov)*; uno sin documentar,|p| — | *sin documentar* |),**Devuelve**tipo: texto,**Lanza**tipo: textopor cada@throwsy**Ejemplo**con el código en un bloquejs. Los|dentro de la tabla, escapados (\|`). - 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(@returntambién vale),tiene @returns pero no devuelve nada(salvo con tipovoidoundefined) yetiqueta desconocida @x(las conocidas: param, returns, return, throws, example, deprecated, private y since). Cada uno como- nombre: aviso. - 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 (oNinguno.).
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 /*.
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.
Ejemplo
/**
* 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));
}
# 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
Solución explicada
Ver la solución completa
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
@exampley compara con el comentario// resultado.