Esta historia ocurrió a mediados de 2023. Finalmente, encontré el tiempo suficiente para escribir al respecto.

Convertir Markdown a DITA manualmente no es divertido

Solía trabajar para una empresa que utilizaba DITA XML para su documentación. Por razones que no puedo discutir debido a acuerdos de confidencialidad (NDA), un puñado de sus productos usaban Markdown (MkDocs) para su documentación en lugar de DITA.

Ejecutar ambos sistemas en paralelo parecía funcionar bien al principio. Sin embargo, eventualmente causó problemas cuando comenzamos a integrar esos productos con el resto de nuestro portafolio, que usaba DITA para la documentación. Necesitábamos reutilizar contenido y mantenerlo sincronizado, lo que significaba recrear casi todo manualmente. Después de unas semanas de copiar y pegar contenido manualmente, mi gerente decidió que debíamos migrar todo a DITA XML.

¿Cómo deberíamos manejar la migración?

Al principio, mi gerente sugirió usar ChatGPT o escribir un script para pasar todo a la API de GPT-3.5/4. No me convencían estas opciones.

Primero, estábamos tratando con información confidencial de la empresa. No teníamos licencias de ChatGPT aprobadas por la empresa, y obtener la aprobación del departamento de InfoSec tomaría semanas o meses. Sí teníamos autorización para usar las APIs de GPT-3.5 y GPT-4, pero aun así no creía que fuera la mejor solución. Procesar grandes volúmenes de XML podría hacer que nuestros costos de API se dispararan, especialmente con GPT-4. Además, sabía que mis compañeros de trabajo no querrían usar una interfaz de línea de comandos varias veces al día solo para hacer su trabajo. Tenía que haber una mejor manera.

Después de buscar en línea y hacer una lluvia de ideas con el recién lanzado Bing Chat, se me ocurrió una solución interesante: crear una aplicación de escritorio que transformara Markdown a DITA.

La lógica era sencilla:

  1. Cargar los archivos Markdown en la aplicación.
  2. Transformar los archivos a HTML.
  3. Manipular ese HTML en DITA XML estructural usando herramientas de desarrollo estándar.

Analizando el Markdown de origen

Antes de escribir cualquier código, necesitaba revisar el Markdown de origen para ver a qué me enfrentaba. Nunca había visto nuestros repositorios de origen de Markdown antes; todo lo que sabía era que “era solo Markdown”.

Después de obtener acceso a los repositorios, analicé los archivos en nuestros proyectos más activos (aquellos que estábamos convirtiendo a DITA manualmente). Después de un par de días de análisis, hice algunos descubrimientos interesantes:

  • No era solo Markdown: Estábamos usando MkDocs con extensiones pesadas para avisos (admonitions), secciones colapsables y fragmentos de contenido.
  • Toneladas de HTML sin procesar: Los autores usaban etiquetas HTML incrustadas como soluciones temporales para las limitaciones de estilo de Markdown, como tablas HTML sin procesar o saltos de línea <br> para forzar diseños.
  • Archivos masivos: Teníamos artículos que superaban las 3,000 líneas. Afortunadamente, la mayoría eran solo tablas HTML gigantes.
  • La consistencia ayudó: Afortunadamente, cada archivo Markdown contenía exactamente un encabezado H1. Esta consistencia resultó muy útil más adelante.

Con esta información, establecí algunos requisitos claros:

  • Buen rendimiento: La herramienta necesitaba procesar cientos de estos archivos grandes rápidamente.
  • Extensibilidad: Necesitaba una librería de Markdown que soportara nuestra sintaxis exacta de MkDocs desde el principio o que me permitiera añadir reglas de sintaxis personalizadas fácilmente.

Seleccionando el stack tecnológico

Quería elegir entre Go y JavaScript, ya que eran los lenguajes que mejor conocía. También necesitaba un framework que hiciera la creación de prototipos rápida y fácil, ya que habían pasado algunos años desde mi último gran proyecto de desarrollo.

Inicialmente, quería usar Go exclusivamente, específicamente combinando GoldMark con Wails. Me encanta la sintaxis de Go y quería exprimir cada bit de rendimiento del motor de conversión. Sin embargo, ese plan chocó con dos obstáculos importantes desde el principio:

  • Debido a nuestro Markdown personalizado, GoldMark tenía problemas para generar HTML correctamente, produciendo artefactos y envolviendo Markdown estándar dentro de bloques de código sin razón aparente.
  • No pude averiguar cómo crear reglas de sintaxis personalizadas fácilmente, y la documentación era escasa.
  • Wails era más difícil de configurar de lo que esperaba. La creación de prototipos tomaría demasiado tiempo.

Como resultado, elegí el ecosistema de JavaScript y TypeScript en su lugar:

  • Markdown-It: Está bien documentado, soporta reglas personalizadas fácilmente y es altamente eficiente.
  • Cheerio: Necesitaba una forma de manipular los archivos HTML resultantes y transformarlos en XML. En ese momento, no me di cuenta de que Node.js y otros entornos de backend carecen de mecanismos nativos de manipulación del DOM. Elegí Cheerio porque era la herramienta más fácil disponible. Esta decisión causaría problemas más tarde al construir la aplicación de escritorio, aunque eventualmente encontré una solución.
  • Neutralinojs: Necesitaba un framework ligero y multiplataforma porque mis compañeros de trabajo usaban tanto macOS como Windows. Neutralinojs era simple y lo suficientemente maduro para esta tarea.
  • Bulma CSS: Lo usé puramente porque ya estaba familiarizado con él.

Construyendo el núcleo

Siendo que la mayoría de mi experiencia como programador estaba en desarrollo backend, decidí crear primero una biblioteca JavaScript para transformar Markdown a XML. Después, desarrollaría la aplicación de escritorio.

Después de leer la documentación de Markdown-It, me di cuenta de que el enfoque más fácil era crear nuevas reglas de renderizado para generar XML en lugar de HTML. Las reglas de renderizado manejarían cerca del 70% del trabajo; solo necesitaba manejar las extensiones de MkDocs y las etiquetas HTML sin procesar por separado.

Primero, creé una clase base abstracta de renderizador para transformar elementos genéricos de Markdown en elementos genéricos de DITA:

import markdownit from "markdown-it";

export abstract class BaseDitaRenderer
{
  protected md = new markdownit({
    html: true,
  });

  constructor() 
  {
    // ...
  }
}

Luego añadí las siguientes reglas al renderizador base:

  • Blockquote (>): Transformado en <lq>.
  • Bloque de código (indentado y delimitado): Transformado en <codeblock> mientras se escapa el contenido interno.
// Bloque de código indentado
this.md.renderer.rules.code_block = (tokens, idx) => `<codeblock>${this.md.utils.escapeHtml(tokens[idx].content)}</codeblock>`;
  • Código en línea: Transformado en <codeph> mientras se escapa el contenido.
this.md.renderer.rules.code_inline = (tokens, idx) => `<codeph>${this.md.utils.escapeHtml(tokens[idx].content)}</codeph>`;
  • Negrita: Transformado en <strong>. El script procesaba esto más tarde para alinearlo con nuestro uso de DITA.
  • Itálica: Transformado en <cite>. No usábamos los elementos tipográficos de DITA, solo los semánticos. Visualmente, <cite> era lo más cercano a la itálica, aunque tuvimos que corregir manualmente el etiquetado más tarde para usar los elementos semánticos correctos.
  • Enlace: Transformado en <xref>.
  • Imagen: Transformado en <image> con una colocación de salto (break).
this.md.renderer.rules.image = (tokens, idx) =>
{
  const srcIndex = tokens[idx].attrIndex("src");
  const srcAttr = tokens[idx].attrs?.[srcIndex];
  const srcValue = srcAttr?.[1];
  const altIndex = tokens[idx].attrIndex("alt");
  const altAttr = tokens[idx].attrs?.[altIndex];
  const altValue = altAttr?.[1];

  return `<image placement="break" href="${srcValue}" alt="${altValue}"/>`;
};
  • Tachado: Se eliminaron las etiquetas <del> y <s> por completo mientras se mantenía el texto dentro de ellas.

Extendiendo el núcleo

Con el renderizador base en su lugar, creé renderizadores específicos para cada tipo de tema DITA que soportábamos (Concepto, Referencia y Tarea). Cada renderizador extendía la clase base y añadía reglas exclusivas para ese tipo de tema.

Los renderizadores de concepto y referencia funcionaban de manera similar:

  • Interceptaban los tokens de encabezado. Si el motor llegaba al inicio del único elemento H1 del archivo, inyectaba el prólogo XML requerido, la declaración doctype del DTD y la etiqueta DITA raíz de apertura.
  • Cualquier encabezado subsiguiente (H2, H3, etc.) se mapeaba provisionalmente a <section>\n<title>.
  • Al cerrar la etiqueta H1, el motor añadía la etiqueta de cuerpo de apertura, como <refbody> o <conbody>.
  • Finalmente, la herramienta comprobaba la presencia del prólogo XML en la cadena de salida. Si faltaba, significaba que el archivo no tenía un H1 y era estructuralmente inválido, lo que provocaba un error.
export class ReferenceRenderer extends BaseDitaRenderer
{
    constructor()
    {
        super();

        this.md.renderer.rules.heading_open = (tokens, idx) => tokens[idx].tag === 'h1' ? `<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE reference PUBLIC "-//OASIS//DTD DITA Reference//EN" "reference.dtd">\n<reference id="topic-id-placeholder" xml:lang="en-us">\n<title>` : `<section>\n<title>`;

        this.md.renderer.rules.heading_close = (tokens, idx) => tokens[idx].tag === 'h1' ? `</title>\n<refbody>\n` : `</title>\n</section>\n`;

    }

    toDitaReference(markdown: string, eventLogger: simpleLogger): string
    {
        try 
        {
            markdown = this.md.render(markdown);

            if (!markdown.includes(`<?xml version="1.0" encoding="utf-8"?>`))
                throw "NoHeaders";
        
            return `${markdown}\n</refbody>\n</reference>`;
        } catch (error)
        {
            eventLogger.logError(`Unable to convert document to DITA XML. Verify your file is properly formatted and try again.\n${error}`);
            return ``;
        }
    }
}

El renderizador de temas de tipo Tarea (Task) usaba la misma lógica pero añadía reglas adicionales para manejar estructuras estrictas de tareas DITA:

  • Transformaba todas las listas ordenadas de nivel 1 en <steps>.
  • Transformaba todos los elementos <li> dentro de esas listas ordenadas de nivel 1 en <step>.
export class TaskRenderer extends BaseDitaRenderer
{
    constructor()
    {
        super();

        this.md.renderer.rules.heading_open = (tokens, idx) => tokens[idx].tag === 'h1' ? `<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE task PUBLIC "-//OASIS//DTD DITA Task//EN" "task.dtd">\n<task id="topic-id-placeholder" xml:lang="en-us">\n<title>` : `<title>`;

        this.md.renderer.rules.heading_close = (tokens, idx) => tokens[idx].tag === 'h1' ? `</title>\n<taskbody>\n` : `</title>\n`;

        this.md.renderer.rules.list_item_open = (tokens, idx) => (tokens[idx].markup === '.' && tokens[idx].level === 1) ? '<step>' : this.md.renderer.renderToken(tokens, idx, {});

        this.md.renderer.rules.list_item_close = (tokens, idx) => (tokens[idx].markup === '.' && tokens[idx].level === 1) ? '</step>\n' : this.md.renderer.renderToken(tokens, idx, {});

        this.md.renderer.rules.ordered_list_open = (tokens, idx) => tokens[idx].level === 0 ? '<steps>\n' : this.md.renderer.renderToken(tokens, idx, {});

        this.md.renderer.rules.ordered_list_close = (tokens, idx) => tokens[idx].level === 0 ? '\n</steps>\n' : this.md.renderer.renderToken(tokens, idx, {});
    }

    toDitaTask(markdown: string, eventLogger: simpleLogger): string
    {
      // Igual que el renderizador de referencia...
    }
}

Aplicando las correcciones preliminares

Las primeras pruebas fueron prometedoras: los renderizadores generaban con éxito cadenas que se asemejaban a DITA XML. Sin embargo, faltaban algunas piezas. Para solucionar esto, añadí una serie de ajustes de preprocesamiento que se ejecutaban directamente sobre los archivos de entrada originales antes del renderizado.

Elementos colapsables

Usábamos la sintaxis no estándar de MkDocs ??? para secciones colapsables. Como Markdown-It no reconocía esta sintaxis, omitía estos bloques por completo. Para solucionar esto, el script los convertía en encabezados ## regulares antes del renderizado:

element = element.includes("???") ? element.replace(`???`, "##").replaceAll(`"`, "").replaceAll(`**`, ``).trim() : element.trim();

Esto introdujo un efecto secundario: para manejar la indentación que usa MkDocs para bloques colapsables, el script tenía que recortar (trim) cada línea en el archivo. Aunque esto funcionaba bien para el texto regular, podía romper bloques de código que dependían de la indentación. Añadí una advertencia al registro (logger) para que los escritores supieran revisar esos archivos después de la conversión.

Reutilización de contenido (conrefs)

Los fragmentos (snippets) de MkDocs usan la sintaxis --8<-- "filename.md" para incluir contenido de otros archivos. Debido a que nuestros repositorios de DITA XML usaban una estructura de archivos completamente diferente, el script no podía resolver estas rutas automáticamente durante la conversión. En lugar de ignorarlas, el script las marcaba como elementos <draft-comment>. Esto permitía a los escritores técnicos localizarlos fácilmente y conectar los conrefs de DITA reales manualmente:

updatedString = element.replace("--8<--", `<draft-comment>Import the contents of `).replace(`.md"`, `.md" here.</draft-comment>\n`)

Aunque no es elegante, hizo que el proceso de limpieza posterior a la conversión fuera manejable.

Correcciones misceláneas de preprocesamiento

Unos pocos ajustes más se ejecutaban antes de que el renderizador tocara los archivos:

  • Los estilos en línea como {: style="color: red"} de los atributos de MkDocs se eliminaban por completo porque no preservábamos el estilo local.
  • El marcador Footnotes: usado en algunos archivos como divisor de sección se eliminó ya que no tenía equivalente en DITA.
  • Se añadía una nueva línea a los encabezados ## para prevenir casos extremos donde Markdown-It fallaba al analizarlos correctamente después de una transformación de elemento colapsable.

Corrigiendo el HTML sobrante

Después de que el renderizador hacía su trabajo, la salida era mayormente DITA XML válido. Sin embargo, Markdown-It dejaba pasar nuestras etiquetas HTML incrustadas sin cambios porque dejé la configuración html: true habilitada. El siguiente paso fue limpiar estos elementos usando Cheerio.

Rutas de menú

En nuestros archivos de origen, las rutas de navegación de la interfaz de usuario como Archivo > Nuevo > Proyecto estaban escritas como texto en negrita. En DITA, esto debe estructurarse usando elementos <menucascade> y <uicontrol>. El script usaba Cheerio para encontrar cada elemento <strong>, verificar si su texto contenía símbolos > o , dividir el texto y envolver cada parte en consecuencia:

  replacement = $('<menucascade></menucascade>');
  parts.forEach(part => {
    replacement.append($(`<uicontrol>${cleanPart}</uicontrol>`));
  });

Si un elemento <strong> no contenía separadores de navegación, se convertía en un simple <uicontrol>. Esto manejó casi todos nuestros casos de uso, ya que el texto en negrita en nuestra documentación se usaba casi exclusivamente para etiquetas de interfaz.

Tablas

Esta fue la corrección más compleja de implementar. Markdown-It renderiza las tablas Markdown como tablas HTML estándar. Necesitaba convertirlas al formato de tabla DITA, el cual se basa en el Modelo de Tabla de Intercambio de OASIS (OASIS Exchange Table Model):

  <table>
    <tgroup cols="3">
      <colspec colname="col1"/>
      <thead><row><entry><p>Header</p></entry></row></thead>
      <tbody><row><entry><p>Cell</p></entry></row></tbody>
    </tgroup>
  </table>

La conversión básica era sencilla, pero las celdas fusionadas eran problemáticas. Algunas tablas HTML usaban atributos colspan y rowspan, los cuales el modelo de tabla de DITA maneja de manera diferente. Escribí una función unmergeCells para expandir las celdas fusionadas en celdas separadas y vacías antes de realizar la conversión. Esto no preservaba perfectamente el diseño, pero mantenía los datos intactos para que los escritores pudieran refinarlos más tarde.

Notas y avisos

MkDocs soporta avisos como !!! note y !!! warning. En nuestros archivos de origen, estos aparecían ya sea como elementos HTML <aside> o como marcadores {: .note} / {: .tip} / {: .warning} al final de un párrafo. Ambos necesitaban mapearse a elementos <note> de DITA:

const noteType = html.includes('.note') ? '' : html.includes('.tip') ? 'tip' : 'warning';

Las notas sin una clase se mapeaban a una <note> DITA simple, mientras que los consejos (tips) y advertencias recibían el atributo de tipo correspondiente.

Corrigiendo la estructura XML

En esta etapa, la salida casi parecía DITA, pero todavía contenía errores estructurales que solo podían resolverse evaluando el documento XML en su conjunto.

Corrigiendo secciones

Markdown no tiene el concepto de envolver contenido entre dos encabezados; simplemente renderiza etiquetas de encabezado de apertura y cierre individuales. Para los temas de concepto y referencia, la salida inicialmente se veía así:

<conbody>
 <section><title>Foo</title></section>
 Lorem ipsum...
 <section><title>Bar</title></section>
 Lorem ipsum...
</conbody>

El contenido quedaba entre los bloques de sección en lugar de dentro de ellos. Escribí una función fixSections que tomaba todos los elementos entre dos etiquetas <section> consecutivas y reestructuraba el árbol para que el contenido quedara dentro de la sección correcta. La última sección se manejaba como un caso especial ya que no tenía elementos posteriores.

Para los temas de referencia, un <refbody> requiere al menos una <section>, incluso si el archivo no tiene encabezados H2. Si la función detectaba que no había secciones, envolvía todo el contenido de <refbody> dentro de una sola sección.

Corrigiendo la estructura de tareas

Las tareas en DITA tienen reglas estructurales estrictas. Después del renderizado inicial, los contenidos del <taskbody> a menudo estaban desordenados o eran inválidos. Una función fixTask resolvió esto usando tres pasos:

  1. Subtareas: Si un archivo de tarea contenía encabezados H2 dentro del cuerpo, se convertían en elementos <task> anidados con su propio <taskbody>. Esto nos permitía manejar archivos que documentaban múltiples procedimientos relacionados en un solo lugar.
  2. Contexto y resultado: El contenido que aparecía antes del elemento <steps> se envolvía en <context>, y el contenido después de él se envolvía en <result>. Si no había pasos, todo iba al <context>.
  3. Estructura de pasos: Cada <step> requiere un elemento <cmd> como su primer hijo. La función reemplazaba el primer <p> dentro de cada paso con <cmd> y envolvía todos los elementos subsiguientes en <info>. También corregía un caso extremo donde las listas anidadas o párrafos terminaban accidentalmente dentro del elemento <cmd> moviéndolos inmediatamente después de él.

Generando IDs de temas

Tanto fixConceptReference como fixTask manejaban la generación de IDs de tema. La cadena id="topic-id-placeholder" inyectada por el renderizador se reemplazaba con un ID derivado del título H1. El script eliminaba caracteres no alfanuméricos, reemplazaba los espacios con guiones bajos y convertía la cadena a minúsculas. Esto generaba IDs legibles por humanos consistentes con nuestros archivos DITA existentes.

La pasada de limpieza final

Una función final fixPendingTasks se ejecutaba en cada tipo de tema para resolver problemas restantes:

  • Cualquier etiqueta <strong> restante que no fuera una ruta de menú se convertía en <uicontrol>.
  • Las etiquetas HTML <a> sin procesar se convertían en elementos <xref>.
  • Los enlaces internos como href="#some-anchor" se prefijaban con el ID del tema para que se resolvieran correctamente en DITA (por ejemplo, href="#my_topic/some-anchor").
  • Los enlaces externos recibían automáticamente atributos format="html" y scope="external".
  • Los elementos <li> sin un hijo <p> tenían uno envuelto alrededor de su contenido para satisfacer los requisitos de DITA.
  • Las etiquetas de anclaje (anchor tags) usadas solo para marcar posiciones en la página (usando un atributo id en lugar de href) se elevaban: su ID se movía al elemento padre, y la etiqueta de anclaje se eliminaba.
  • Una función fixAnchorIdForTitles manejaba la convención de MkDocs ## My Section {#custom-id}. Extraía el ID personalizado, lo aplicaba al elemento <title> y eliminaba el rastreador de sintaxis del texto del título.

Conclusión

Eso forma el núcleo de la herramienta: un parser de Markdown-It extendido con reglas personalizadas para cada tipo de tema DITA, scripts de preprocesamiento para la sintaxis de MkDocs y pases de post-procesamiento para asegurar que el XML valide contra los DTDs de DITA.

Una vez que la librería principal funcionó, construí una aplicación web y una aplicación de escritorio para que los escritores pudieran convertir archivos rápidamente. Debido a que eliminamos gradualmente los sitios web de MkDocs, los escritores actualizaban primero el sitio de MkDocs y luego usaban la herramienta para portar sus cambios a los repositorios de DITA.

¿Funcionó? En su mayor parte, sí. La salida aún requería revisión humana para corregir parte del etiquetado semántico, ajustar los archivos DITAMAP y reparar archivos con HTML complejo sin procesar. Sin embargo, eliminó la mayor parte del trabajo mecánico. Convertir un archivo pasó de tomar de 30 minutos a unas pocas horas, a solo unos segundos. Los escritores podían concentrarse en partes de la documentación que requerían juicio editorial en lugar de copiar y pegar etiquetas XML toda la tarde.

Si estuviera empezando esto hoy, probablemente tomaría algunas decisiones diferentes, las cuales cubriré en la próxima publicación. Empaquetar esto como una librería fue la parte fácil. Envolverlo en una aplicación de escritorio introdujo un conjunto completamente nuevo de dolores de cabeza, y cierta librería de manipulación del DOM que parecía una opción perfectamente razonable en ese momento tenía otros planes.