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

Convertir manualmente de Markdown a DITA no es divertido

Solía trabajar para una empresa tecnológica que utilizaba DITA XML para su documentación. Por razones que no puedo discutir debido a acuerdos de confidencialidad (NDA), alrededor de cinco productos usaban MkDocs para su documentación en lugar de DITA.

Ejecutar ambos sistemas en paralelo parecía estar bien al principio. Sin embargo, eventualmente causó problemas cuando empezamos a integrar esos productos con el resto de nuestro portafolio, que utilizaba DITA para la documentación. Necesitábamos reutilizar contenido y mantenerlo sincronizado, lo que significaba recrear manualmente la mayoría de las cosas. 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 quería usar ChatGPT o escribir un script para pasar todo a la API de GPT-3.5 Turbo. No estaba convencido con estas opciones.

Primero, estábamos tratando con información confidencial de la empresa. No teníamos licencias de ChatGPT aprobadas por la compañía, y obtener la aprobación del departamento de Seguridad de la Información (InfoSec) tomaría semanas o meses. Teníamos acceso a las APIs de GPT-3.5 y GPT-4 a través de Azure OpenAI, 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 directa:

  1. Cargar los archivos Markdown en la aplicación.
  2. Transformar los archivos a HTML.
  3. Manipular ese HTML para convertirlo en DITA XML estructural utilizando herramientas estándar para desarrolladores.

Analizando el Markdown de origen

Antes de escribir código, necesitaba revisar el Markdown de origen para ver a qué me enfrentaba. Nunca había visto nuestros repositorios de origen en Markdown; 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 en ese momento). Tras un par de días de análisis, hice algunos descubrimientos interesantes:

  • No era solo Markdown: Estábamos usando MkDocs con extensiones pesadas para advertencias (admonitions), secciones plegables y fragmentos de contenido.
  • Toneladas de HTML sin procesar: Los autores usaban etiquetas HTML incrustadas como soluciones alternativas a 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 debía procesar cientos de estos archivos grandes rápidamente.
  • Extensibilidad: Necesitaba una librería de Markdown que soportara nuestra sintaxis exacta de MkDocs de forma nativa o que me permitiera agregar 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 el prototipado rápido y sencillo, ya que habían pasado algunos años desde mi último proyecto de desarrollo importante.

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

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

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

  • Markdown-It: Está bien documentado, soporta reglas personalizadas fácilmente y tiene un alto rendimiento.
  • 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 alternativa.
  • Neutralinojs: Necesitaba un framework multiplataforma ligero porque mis compañeros de trabajo usaban tanto macOS como Windows. Neutralinojs era lo suficientemente simple y maduro para esta tarea.
  • Bulma CSS: Lo usé puramente porque ya estaba familiarizado con él.

Construyendo el núcleo

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 alrededor del 70% del trabajo; solo necesitaba manejar las extensiones personalizadas 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 agregué las siguientes reglas al renderizador base:

  • Blockquote (>): Transformado en <lq>.
  • Bloque de código (sangrado y con delimitadores): Transformado en <codeblock> mientras se escapa el contenido interno.
// Bloque de código con sangrado
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 alinearse con nuestro uso de DITA.
  • Cursiva: Transformado en <cite>. No usábamos elementos tipográficos de DITA, solo elementos semánticos. Visualmente, <cite> era lo más parecido a las cursivas, 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 ubicación de salto (break placement).
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 por completo las etiquetas <del> y <s> manteniendo el texto dentro de ellas.

Extendiendo el núcleo

Con el renderizador base implementado, creé renderizadores específicos para cada tipo de tema de DITA que soportábamos (Concepto, Referencia y Tarea). Cada renderizador extendía la clase base y agregaba 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 detectaba el inicio del elemento H1 único del archivo, inyectaba el prólogo XML requerido, la declaración doctype DTD y la etiqueta DITA raíz de apertura.
  • Cualquier encabezado posterior (H2, H3, etc.) se mapeaba provisionalmente a <section>\n<title>.
  • Al cerrar la etiqueta H1, el motor añadía la etiqueta del cuerpo de apertura, como <refbody> o <conbody>.
  • Finalmente, la herramienta verificaba 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 tareas (task) usaba la misma lógica pero añadía reglas adicionales para manejar estructuras de tareas estrictas de DITA:

  • Transformó todas las listas ordenadas de nivel 1 en <steps>.
  • Transformó 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 generaron 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 en los archivos de entrada sin procesar antes de la renderización.

Elementos plegables

Usábamos la sintaxis no estándar de MkDocs ??? para secciones plegables. Debido a que 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 de renderizar:

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

Esto introdujo un efecto secundario: para manejar la sangría que usa MkDocs para bloques plegables, el script tenía que recortar cada línea del archivo. Aunque esto funcionaba bien para texto normal, podía romper bloques de código que dependían de la sangría. Añadí una advertencia al logger para que los redactores supieran verificar dos veces 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 marcó como elementos <draft-comment>. Esto permitió a los redactores técnicos localizarlas fácilmente y configurar los conrefs reales de DITA 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

Algunos otros ajustes 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 eliminaron por completo porque no estábamos conservando el estilo local.
  • El marcador Footnotes: utilizado en algunos archivos como divisor de sección se eliminó ya que no tenía equivalente en DITA.
  • Se añadió una nueva línea a los encabezados ## para evitar casos extremos en los que Markdown-It no lograba analizarlos correctamente después de una transformación de elemento plegable.

Corrigiendo HTML restante

Después de que el renderizador hizo su trabajo, la salida era mayormente DITA XML válido. Sin embargo, Markdown-It pasaba nuestras etiquetas HTML incrustadas sin cambios porque dejé habilitada la configuración html: true. 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, estas deben estructurarse usando elementos <menucascade> y <uicontrol>. El script usó 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 la interfaz de usuario.

Tablas

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

 <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 directa, pero las celdas combinadas eran problemáticas. Algunas tablas HTML usaban atributos colspan y rowspan, que el modelo de tabla de DITA maneja de manera diferente. Escribí una función unmergeCells para expandir las celdas combinadas en celdas separadas y vacías antes de realizar la conversión. Esto no preservó perfectamente el diseño, pero mantuvo los datos intactos para que los redactores pudieran perfeccionarlos más tarde.

Notas y advertencias

MkDocs admite advertencias (admonitions) como !!! note y !!! warning. En nuestros archivos de origen, estas aparecían como elementos HTML <aside> o como marcadores {: .note} / {: .tip} / {: .warning} al final de un párrafo. Ambos necesitaban ser mapeados a elementos <note> de DITA:

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

Las notas sin clase se mapeaban a un <note> de 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 manejó 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 no detectaba secciones, envolvía todo el contenido del <refbody> dentro de una sola sección.

Corrigiendo la estructura de tareas

Las tareas de DITA tienen reglas estructurales estrictas. Después de la renderización 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 permitió 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 posteriores en <info>. También corrigió un caso extremo donde las listas o párrafos anidados terminaban accidentalmente dentro del elemento <cmd> al moverlos inmediatamente después de este.

Generando IDs de temas

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

La 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 <a> de HTML 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 los 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) utilizadas solo para marcar posiciones en la página (usando un atributo id en lugar de href) eran elevadas: su ID se movía al elemento padre y la etiqueta de anclaje se eliminaba.
  • Una función fixAnchorIdForTitles manejó 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 constituye el núcleo de la herramienta: un analizador Markdown-It extendido con reglas personalizadas para cada tipo de tema DITA, scripts de preprocesamiento para la sintaxis de MkDocs y pasadas de post-procesamiento para asegurar que el XML se 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 redactores pudieran convertir archivos rápidamente. Debido a que eliminamos gradualmente los sitios web de MkDocs, los redactores actualizaban el sitio de MkDocs primero, luego usaban la herramienta para trasladar sus cambios a los repositorios de DITA.

¿Funcionó? En su mayor parte, sí. La salida todavía requería revisión humana para corregir algo de etiquetado semántico, ajustar los archivos DITAMAP y arreglar archivos con HTML sin procesar complejo. 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 redactores podían concentrarse en partes de la documentación que requerían criterio 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 todo un nuevo conjunto 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.