@ngx-docs-markdown-kit/parser-md
v0.1.0
Published
Parser de Markdown extensible para sitios de documentacion -- nucleo agnostico de framework (sin dependencias de Angular), basado en marked. Define el punto de extension (.use()) que consumen los paquetes parser-md-seo y parser-md-code-block.
Downloads
174
Maintainers
Readme
@ngx-docs-markdown-kit/parser-md
Núcleo del kit: parsea Markdown (vía marked) a una estructura de datos tipada (DocPageContent), agnóstica de framework -- sin ninguna dependencia de Angular, usable en cualquier proyecto de Node/TypeScript. Cada encabezado se convierte en un SectionSegment anidado de verdad (un ### dentro de un ## queda como hijo real, no como texto indentado), para que el consumidor pueda renderizar un <section> real por encabezado.
No conoce ni importa ninguna extensión concreta -- expone un único punto de registro, .use(extension), con 3 hooks opcionales (transformToken/buildSegment/extendMeta) para que parser-md-seo, parser-md-code-block, parser-md-image, parser-md-card (y cualquier extensión futura) se enchufen sin que este paquete tenga que importarlas jamás:
import { createParser } from '@ngx-docs-markdown-kit/parser-md';
import { seoExtension } from '@ngx-docs-markdown-kit/parser-md-seo';
import { codeBlockExtension } from '@ngx-docs-markdown-kit/parser-md-code-block';
const parser = createParser().use(seoExtension()).use(codeBlockExtension());
const result = parser.parse(markdownSource);
// result.segments (árbol de secciones), result.headings, result.metaQué trae
ParserMd/createParser()-- el parser en sí.MarkdownEngine(interfaz mínima{ lex, render }) es la abstracción real detrás demarked-- si algún día hiciera falta cambiar de motor, alcanza con implementar esa interfaz (MarkedEnginees la única implementación real hoy).- Frontmatter propio, no YAML --
---\nclave: valor\n---(líneas planas, sin anidar; también soporta[og:image:url]: valorpara claves con:). Deliberadamente mínimo: el formato real que se necesita no justifica traer una librería YAML completa. parseKeyValueLines-- el mismo parser de camposclave: valorque usa el frontmatter, exportado para que extensiones externas (parser-md-image,parser-md-card) lo reusen en vez de reimplementarlo cada una por su cuenta.slugify-- mismo algoritmo de generación de ids de encabezado que usa el parser internamente, exportado para que un consumidor externo pueda calcular el mismo id sin duplicar la lógica.cardsExtension()-- reconoce fences```cards(una líneahref|título|descripciónpor card) y produce unCardsSegment. Vive en el núcleo (no en un paquete aparte) porque es el mecanismo que usa el propio manifiesto de contenido para las páginas de grupo autogeneradas (___content_dir.md).- Callouts --
> [!TIP]\n> textose convierte en una caja de aviso destacado. También en el núcleo por ser formato de prosa genérico, no una extensión de terceros.
@ngx-docs-markdown-kit/parser-md/manifest (entry point secundario)
Escaneo real de una carpeta en disco (usa node:fs/node:path) para construir el árbol de navegación completo de un sitio -- buildContentManifest(contentDir). Es un entry point aparte para que un consumidor que solo necesita esto (típicamente un script de build) no cargue nada del resto del paquete.
Reglas del escaneo: cada carpeta con contenido navegable necesita su propio ___meta.md; el [slug] de cada página es siempre explícito en el frontmatter, nunca derivado del nombre del archivo en disco (el nombre en disco es 100% cosmético); el orden entre hermanos es alfabético por defecto, o el explícito de un ___order.md si existe en esa carpeta.
Este es el mecanismo real detrás de create-ngx-docs-site (barras de navegación, sitemap, BreadcrumbList de SEO) y de parser-md-converter (README generado a partir del mismo árbol) -- ambos consumen el mismo manifiesto como única fuente de verdad de navegación/contenido, así que nunca pueden desincronizarse entre sí.
Documentación completa
Arquitectura interna, el contrato de extensión (ParserMdExtension) con ejemplos reales de cómo escribir una extensión propia, y la referencia completa del formato de frontmatter/manifiesto: ver el sitio de documentación de este paquete, parser-md.frugocorp.com.
parser-md

Dependencias:
@angular/animations: ^22.1.0@angular/cdk: ^22.1.0@angular/common: ^22.1.0@angular/compiler: ^22.1.0@angular/core: ^22.1.0@angular/forms: ^22.1.0@angular/material: ^22.1.0@angular/platform-browser: ^22.1.0@angular/platform-server: ^22.1.0@angular/router: ^22.1.0@angular/ssr: ^22.1.3@ngx-docs-markdown-kit/parser-md: file:./vendor/parser-md/ngx-docs-markdown-kit-parser-md-0.1.0.tgz@ngx-docs-markdown-kit/parser-md-seo: file:./vendor/parser-md-seo/ngx-docs-markdown-kit-parser-md-seo-0.1.0.tgz@ngx-docs-markdown-kit/parser-md-code-block: file:./vendor/parser-md-code-block/ngx-docs-markdown-kit-parser-md-code-block-0.1.0.tgz@ngx-docs-markdown-kit/parser-md-code-block-themes: file:./vendor/parser-md-code-block-themes/ngx-docs-markdown-kit-parser-md-code-block-themes-0.1.0.tgz@ngx-docs-markdown-kit/parser-md-image: file:./vendor/parser-md-image/ngx-docs-markdown-kit-parser-md-image-0.1.0.tgz@ngx-docs-markdown-kit/parser-md-card: file:./vendor/parser-md-card/ngx-docs-markdown-kit-parser-md-card-0.1.0.tgz@ngx-docs-markdown-kit/parser-md-converter: file:./vendor/parser-md-converter/ngx-docs-markdown-kit-parser-md-converter-0.1.0.tgz@ngx-docs-markdown-kit/ui: file:./vendor/ui/ngx-docs-markdown-kit-ui-0.1.0.tgzrxjs: ~7.8.0tslib: ^2.3.0
Apartados de parser-md
- Docs -- Documentación de @ngx-docs-markdown-kit/parser-md -- núcleo del parser de Markdown, agnóstico de framework, con punto de extensión .use().
- Parser MD -- Parser de Markdown extensible, agnostico de framework, nucleo de todo el ecosistema @ngx-docs-markdown-kit.
Docs de parser-md
REGRESAR A APARTADOS DE parser-md
Índice Docs de parser-md
- Primeros pasos -- Qué es parser-md y cómo instalarlo.
- Instalación -- Cómo instalar parser-md y armar tu primer parser.
- Uso -- El API real del núcleo -- parseo, extensiones, frontmatter y el manifiesto de contenido.
- Parsear Markdown -- La forma DocPageContent -- segments, headings y meta.
- Frontmatter y parseKeyValueLines -- parseFrontmatter, el formato clave:valor, y slugify.
- El manifiesto de contenido -- buildContentManifest() -- escanea una carpeta de .md a una estructura tipada, reglas de slug/orden.
- Escribir una extensión -- La interfaz ParserMdExtension -- transformToken, buildSegment, extendMeta.
- Arquitectura interna -- Cómo está construido parser-md por dentro -- el algoritmo de .parse() y las decisiones de diseño detrás.
- Decisiones de diseño -- Por qué parser-md está construido así -- DIP sobre el motor, ids sin duplicar, formato mínimo propio.
- El pipeline de .parse() -- Recorrido completo del algoritmo detrás de ParserMd.parse() -- frontmatter, tokens, secciones, prosa.
- Ecosistema -- Quién consume parser-md y cómo se relacionan entre sí las extensiones del kit.
- Relación con el ecosistema -- Quién depende de parser-md, la dependencia cruzada parser-md-card -> parser-md-image, y el manifiesto compartido.
Primeros pasos de parser-md
Instalación de parser-md
npm install @ngx-docs-markdown-kit/parser-mdSin dependencias de Angular -- parser-md es 100% agnóstico de framework, basado en
marked por debajo. Sirve para parsear Markdown en cualquier contexto
Node/browser, no solo dentro de un sitio create-ngx-docs-site.
Tu primer parser
import { createParser } from '@ngx-docs-markdown-kit/parser-md';
const parser = createParser();
const page = parser.parse(`---
[title]: Mi página
---
# Mi página
Contenido normal en **Markdown**.`);
console.log(page.meta.title); // 'Mi página'
console.log(page.headings); // [{ id: 'mi-pagina', text: 'Mi página', level: 1 }]
console.log(page.segments); // [{ type: 'html', html: '...' }]createParser() no conoce ni importa ninguna extensión específica -- expone un único punto de
registro (.use(extension)) que consumen parser-md-seo, parser-md-code-block, parser-md-image,
parser-md-card, etc. Ver Escribir una extensión para agregar las
tuyas.
Paquete ESM puro
package.json declara "type": "module" y un mapa de exports con 2 entry points -- no hay
ninguna variante CommonJS (require), así que el proyecto que lo consuma también tiene que resolver
módulos ESM (import, no require):
// entry point raíz -- parseo puro, sin tocar el filesystem
import { createParser, cardsExtension, slugify, parseFrontmatter, parseKeyValueLines } from '@ngx-docs-markdown-kit/parser-md';
// entry point secundario -- usa node:fs/node:path real, aparte para no cargarlo si no hace falta
import { buildContentManifest } from '@ngx-docs-markdown-kit/parser-md/manifest';Sin dependencia de Angular ni de ningún otro paquete del kit -- la única dependencia real es
marked (^18). Sirve igual de bien parseando un string suelto en un
script, un test, o (el caso real de uso) el contenido de un sitio create-ngx-docs-site.
Próximos pasos
- Parsear Markdown -- la forma
DocPageContentque devuelve.parse(). - El manifiesto de contenido --
buildContentManifest(), el entry point/manifest. - Escribir una extensión -- cómo se enchufan
parser-md-seo/parser-md-code-block/etc.
Uso de parser-md
Parsear Markdown de parser-md
parser.parse(markdown) devuelve un DocPageContent, agnóstico de framework (sin HTML de
Angular, sin nada especifico de un renderer puntual):
interface DocPageContent {
readonly segments: DocPageSegment[];
readonly headings: DocPageHeading[];
readonly meta: DocPageMeta;
}headings-- lista plana de{id, text, level}, un item por cada heading real del cuerpo (el H1 del título de la página normalmente no cuenta -- vive fuera del cuerpo, en el frontmatter). Elidsale deslugify(), la misma función que exporta este paquete.meta--Record<string, unknown>acumulado por las extensiones instaladas a partir del frontmatter --parser-mdno le da significado a ningún campo en particular, solo lo expone (verparser-md-seopara el uso real dedescription/keywords/etc.).segments-- el cuerpo ya estructurado, cada uno con su propiotype:{ type: 'html', html }-- un tramo de prosa normal, ya renderizado.{ type: 'list', ordered, items }-- una lista con contenido anidado (código, otro segmento no-prosa dentro de un item) --itemses un array deDocPageSegment[], recursivo.{ type: 'section', id, level, titleHtml, segments }-- un heading + TODO su contenido, de forma recursiva: unh3dentro de unh2aparece como unSectionSegmentDENTRO desegmentsdelh2, no solo indentado -- pensado para que el consumidor renderice un<section>real por cada uno (requisito de SEO, verparser-md-seo).{ type: 'cards', cards }-- una grilla de tarjetas, la producecardsExtension()(ver más abajo) a partir de un fence```cards.- Cualquier extensión instalada (
parser-md-image,parser-md-code-block, etc.) puede agregar sus propios tipos -- el consumidor discrimina portypecon un@switch/switch.
Extensión incluida: cardsExtension()
import { createParser, cardsExtension } from '@ngx-docs-markdown-kit/parser-md';
const parser = createParser().use(cardsExtension());Reconoce un fence ```cards cuyo cuerpo es una línea href|título|descripción por card (sin
escape de |) y produce un CardsSegment. No está pensado para escribirse a mano -- lo genera
buildContentManifest() (ver El manifiesto de contenido) por cada
carpeta de contenido, como grilla de sus hijos directos.
Cómo funciona por dentro
El anidamiento de SectionSegment es enteramente FABRICADO por parser-md (marked solo entrega
una lista plana de tokens, sin relación estructural entre un ## y el ### que sigue), y el id
de cada heading se calcula una sola vez y se comparte entre los dos lugares que lo necesitan. Ver
Arquitectura interna para el algoritmo completo de anidamiento
y Decisiones de diseño para el detalle del cacheo de id.
Frontmatter y parseKeyValueLines de parser-md
El frontmatter usa la misma convención que Jekyll/Hugo/Astro (--- ... --- al principio del
archivo) pero DELIBERADAMENTE no usa una librería de YAML real -- el formato soportado es mínimo a
propósito: líneas planas clave: valor, nada anidado.
import { parseFrontmatter } from '@ngx-docs-markdown-kit/parser-md';
const { frontmatter, body } = parseFrontmatter(`---
[title]: Mi página
[slug]: mi-pagina
---
# Mi página`);
// frontmatter = { title: 'Mi página', slug: 'mi-pagina' }
// body = '\n# Mi página'Los valores quedan CRUDOS, sin interpretar -- convertir keywords a un array, por ejemplo, es
responsabilidad de la extensión que consuma ese campo puntual (ver parser-md-seo), no del núcleo.
Dos formatos por línea, ambos válidos
parseFrontmatter/parseKeyValueLines (exportada aparte, la reusa cualquier extensión con su
propio bloque de campos, ej. el fence ```image de parser-md-image) aceptan:
clave: valor-- el de siempre, corta en el primer:de la línea.[clave]: valor-- con corchetes -- la clave puede tener CUALQUIER carácter, incluidos:(ej.[og:image:url]: /x.jpg), porque el corte es por el]:que cierra el corchete, no por el primer:de la línea. Ambos formatos conviven en el mismo bloque sin problema.
Una línea sin clave o sin valor (a cualquiera de los 2 lados del separador) se ignora, no rompe el parseo del resto.
Por qué parseKeyValueLines vive en el núcleo
No es solo el algoritmo del frontmatter -- se exporta aparte porque más de una extensión externa
necesita el MISMO formato clave: valor para su propio bloque de campos dentro de un fence propio
(ej. src/alt/align en el fence ```image de parser-md-image). Antes de esta extracción,
cada extensión tenía su propia copia casi idéntica del algoritmo, sin nada que las mantuviera
sincronizadas si una corregía una regla de borde (comentarios, escapes, líneas vacías) y la otra no
-- ahora hay una sola fuente de verdad, y las extensiones la importan desde parser-md en vez de
reimplementarla.
slugify
import { slugify } from '@ngx-docs-markdown-kit/parser-md';
slugify('Política de Privacidad'); // 'politica-de-privacidad'Quita acentos (normaliza NFD, descarta la marca de acento combinante) y cualquier carácter que no
sea letra/número/guion, después baja a minúsculas y cambia espacios por guiones -- la misma función
que arma el id de cada heading en DocPageHeading. Exportada para que un consumidor externo (ej.
un panel "en esta página", o un test de integridad de links) calcule el MISMO id sin duplicar el
algoritmo.
El manifiesto de contenido de parser-md
@ngx-docs-markdown-kit/parser-md/manifest (entry point APARTE del núcleo -- usa node:fs/
node:path reales, así que un consumidor que solo necesita parsear un string de Markdown no carga
nada de esto):
import { buildContentManifest } from '@ngx-docs-markdown-kit/parser-md/manifest';
const manifest = buildContentManifest('./content-parser-md');
// { pages: ManifestPage[], groups: ManifestGroup[] }Escanea recursivamente una carpeta real en disco y devuelve la estructura completa -- lo usa
generate-content-manifest.mjs (el script que corre create-ngx-docs-site antes de cada
npm start/npm run build) para armar el manifiesto de navegación del sitio, pero es reusable por
cualquier consumidor que necesite lo mismo (ej. parser-md-seo para el sitemap.xml).
Reglas reales
- El
sluges SIEMPRE explícito --[slug]: valoren el frontmatter de la página, o en el___meta.mdde una carpeta -- NUNCA se deriva del nombre de archivo. El nombre en disco es 100% cosmético (un equipo puede usar prefijos de orden propios, mayúsculas, lo que prefiera) sin que eso rompa ninguna URL. Solo acepta minúsculas/números/guiones simples entre segmentos (/^[a-z0-9]+(-[a-z0-9]+)*$/) -- un slug inválido o faltante frena el escaneo con un error que dice exactamente qué archivo lo necesita, nunca una URL rota en silencio. - El orden entre hermanos es alfabético por nombre CRUDO de archivo/carpeta por default, o el
orden explícito de un
___order.mden esa carpeta (una lista de nombres de archivo, uno por línea) si existe -- lo que no aparezca en la lista cae al final, alfabético. - Una carpeta con contenido navegable necesita su propio
___meta.md-- mismo formato de frontmatter que cualquier página ([slug]/[title]/[description]), obligatorio (nunca{}). sourcePathvs.group/slug--sourcePathes la ruta REAL en disco (para quien necesite leer/re-escribir el archivo),group/slugson para navegación/URLs -- deliberadamente independientes, no se puede reconstruir uno a partir del otro.
Headings con jerarquía (ManifestHeading)
Cada ManifestPage.headings extiende DocPageHeading (ver
Parsear Markdown) con parentId: string | null -- el heading
anterior más cercano con nivel MENOR (null para el primero de la página, o si no hay ninguno con
nivel menor antes). Se calcula en el manifiesto, no en ParserMd, porque "quién es hijo de quién"
es una necesidad de navegación/manifiesto, no del renderizado del cuerpo en sí. El cálculo es un
solo recorrido con una pila de "ancestros candidatos": cada vez que aparece un heading nuevo, se
descartan de la pila todos los que tengan nivel >= al suyo (ya no pueden ser ancestro de nada que
venga después), y el que quede arriba (si queda alguno) es su padre.
lastmod
Cada ManifestPage.lastmod sale de la fecha de modificación REAL del archivo en disco
(statSync(entryPath).mtime, recortada a AAAA-MM-DD) -- no de un campo de frontmatter que un
autor tenga que mantener a mano. Es el dato que usa parser-md-seo para el <lastmod> de cada URL
en el sitemap.xml generado.
Ver Relación con el ecosistema para quién más consume este mismo manifiesto (navegación, sitemap, README generado) y por qué nunca pueden desincronizarse entre sí.
Escribir una extensión de parser-md
parser-md no conoce ni importa ninguna extensión específica -- expone un único punto de registro:
const parser = createParser().use(miExtension()).use(otraExtension());Cada extensión implementa ParserMdExtension (los 3 métodos son todos opcionales):
interface ParserMdExtension {
readonly name: string;
transformToken?(token: Token, ctx: ParserMdContext): Token;
buildSegment?(token: Token, ctx: ParserMdContext): DocPageSegment | undefined;
extendMeta?(meta: DocPageMeta, frontmatter: Readonly<Record<string, string>>, ctx: ParserMdContext): void;
}transformToken-- transforma un token en otro ANTES de construir segmentos (ej. un blockquote> [!TIP]que se convierte en el HTML de un aviso destacado). Se aplica en el orden en que las extensiones se registraron con.use(). Devolvé el mismo token si no aplica.buildSegment-- intercepta un token para producir un segmento propio -- el caso más común es un fence con un lenguaje reservado (```image,```code-block, etc.):token.type === 'code'ytoken.langmatchea tu convención. Devolvéundefinedsi no te corresponde -- el núcleo sigue probando con la siguiente extensión, y si ninguna lo reclama, el fence cae al render por default de Markdown.extendMeta-- corre una vez por página, ANTES de tokenizar el cuerpo, para completarmetaa partir del frontmatter ya parseado (valores crudos, sin interpretar -- interpretarlos es responsabilidad tuya).
Ejemplo mínimo real
Una extensión que reconoce ```highlight y lo baja a una nota en negrita:
import type { DocPageSegment, ParserMdExtension } from '@ngx-docs-markdown-kit/parser-md';
import type { Token, Tokens } from 'marked';
export function highlightExtension(): ParserMdExtension {
return {
name: 'highlight',
buildSegment(token: Token): DocPageSegment | undefined {
if (token.type !== 'code') return undefined;
const codeToken = token as Tokens.Code;
if ((codeToken.lang ?? '').trim() !== 'highlight') return undefined;
return { type: 'html', html: `<strong>${codeToken.text}</strong>` };
},
};
}Mismo patrón real que usan parser-md-image/parser-md-code-block/parser-md-card -- ver sus
propios sitios de documentación para ejemplos más completos (fences con múltiples campos, ctx.lex
para re-analizar contenido anidado).
El orden de .use() importa
buildSegment-- el núcleo prueba las extensiones EN EL ORDEN en que se registraron y se queda con la primera que devuelve un segmento; las siguientes ni se llaman para ese token. Si dos extensiones reconocieran el mismolangde fence, gana la que se registró primero -- en la práctica no pasa porque cada una usa su propio nombre reservado (image,code-block,content-card,cards), pero el orden de registro es lo que decide un empate si alguna vez lo hubiera.transformToken-- se aplica en cadena, TODAS las extensiones, siempre en el orden de registro (nunca hay "la primera gana" acá) -- cada una recibe el token ya transformado por la anterior. El callout> [!TIP]del núcleo (transformCallout) corre primero, antes que cualquier extensión instalada.extendMeta-- también en cadena, todas, mismo orden -- cada extensión ve elmetaya completado por las anteriores, así que puede leer (no solo escribir) lo que otra extensión registrada antes ya puso ahí.
ParserMdContext
Las 3 funciones que le pasa el núcleo a cada extensión, para operar con el MISMO motor (nunca
instanciar marked por tu cuenta dentro de una extensión):
ctx.lex(markdown)-- tokeniza un fragmento (ej. el interior de tu propio fence).ctx.render(tokens)-- renderiza tokens a HTML, con el mismo renderer de heading-con-id que usa el resto de la página.ctx.buildSegments(tokens)-- vuelve a aplicar el pipeline COMPLETO de construcción de segmentos (útil si tu fence contiene Markdown libre que a su vez puede usar otras extensiones).
Arquitectura interna de parser-md
Decisiones de diseño de parser-md
< Arquitectura interna de parser-md
Un recorrido de las decisiones menos obvias del núcleo -- el "por qué", no solo el "qué" (eso ya está en Uso y en El pipeline de .parse()).
MarkdownEngine -- Inversión de Dependencias real, no de nombre
ParserMd nunca importa marked directo -- depende de la interfaz MarkdownEngine { lex, render }
(markdown-engine.ts), y recibe una implementación concreta por constructor (MarkedEngine por
default). No es una abstracción decorativa: hay un test (parser.spec.ts) que le pasa a
createParser() un MarkdownEngine FALSO (que ni siquiera usa marked) y confirma que
ParserMd sigue funcionando igual -- si algún día marked dejara de servir (rendimiento, tamaño de
bundle, lo que sea), alcanza con escribir un MarkdownEngine nuevo, sin tocar parser.ts ni
ninguna extensión (todas ven ctx.lex/ctx.render, nunca el motor directo).
Por qué el heading nunca lleva id en el HTML
MarkedEngine sobreescribe el renderer de heading para que devuelva <h2>Texto</h2>, nunca
<h2 id="...">. La razón es evitar que el mismo id termine escrito en 2 lugares del DOM: el
SectionSegment que envuelve a ese heading ya lleva su propio id (ver paso 5 de
El pipeline de .parse()), pensado para que el consumidor lo
ponga en el elemento <section id="..."> que envuelve al heading, no en el heading mismo. Si el
<h2> también trajera id, un ancla (#mi-seccion) podría matchear cualquiera de los 2 elementos
según cuál quedara primero en el DOM -- una ambigüedad evitable con una sola regla: el id vive en la
sección, punto.
Por qué el frontmatter no es YAML real
El formato soportado es deliberadamente mínimo -- líneas planas clave: valor (o [clave]: valor
con corchetes), sin anidamiento, sin listas, sin tipos. No es una limitación técnica (agregar una
dependencia de YAML sería trivial) sino una decisión: todo el contenido real del ecosistema (títulos,
descripciones, slugs, keywords, campos de un fence de imagen) es una lista corta de pares planos, y
un regex + split (parseKeyValueLines, ver
Frontmatter y parseKeyValueLines) alcanza para eso sin arrastrar un
parser YAML completo (con todo lo que implica: tipos implícitos, anclas, fechas parseadas solas,
etc.) para un caso de uso que nunca lo necesita.
WeakMap a nivel de módulo, no de instancia
headingIds (parser.ts) es un WeakMap<Tokens.Heading, string> declarado FUERA de la clase
ParserMd, a nivel de módulo. La clave es el objeto token en sí (identidad, no texto) -- un mismo
heading pasa por 2 caminos independientes dentro de un solo .parse() (la lista headings y su
SectionSegment), y ambos necesitan el id IDÉNTICO. Ser un WeakMap (no un Map) es intencional:
no retiene el token en memoria más allá de lo que ya lo retiene el resto del árbol de tokens -- una
vez que .parse() termina y el árbol se descarta, la entrada se libera sola, sin necesitar limpieza
manual.
Por qué callouts.ts y cards-extension.ts viven en el núcleo, no en paquetes aparte
Ambos podrían haber sido extensiones externas (implementan el mismo contrato,
transformToken/buildSegment), pero se quedaron en parser-md por razones distintas:
- Callouts (
> [!TIP]) es formato de PROSA genérico -- no depende de ningún concepto externo (SEO, resaltado de código, imágenes), es una convención de Markdown extendido comparable a negrita o listas. No tiene sentido instalarlo aparte cuando cualquier consumidor deparser-mdlo va a querer. - Cards (fence
```cards) vive acá porque su único consumidor real,___content_dir.md(generado automáticamente, ver El manifiesto de contenido), es parte del MISMO mecanismo de manifiesto que ya vive en este paquete (buildContentManifest) -- separarlo en un paquete aparte hubiera significado que el propio núcleo (content-manifest.ts) dependiera de una extensión externa para generar contenido consumible por sí mismo, una dependencia circular de diseño que se evita manteniendo ambas piezas juntas.
Por qué key-value-lines.ts es un módulo propio (y no parte de frontmatter.ts)
Aunque el frontmatter es su único consumidor DENTRO de este paquete, parseKeyValueLines se exporta
desde parser-md (no queda privado dentro de frontmatter.ts) porque extensiones externas la
necesitan para sus propios bloques de campos dentro de un fence (parser-md-image, por ejemplo, con
src/alt/align/etc. dentro de un fence ```image). Antes de esta extracción cada extensión
tenía su propia copia casi idéntica del algoritmo; separarlo en su propio módulo, con su propia
exportación pública, es lo que hace posible que sea una sola fuente de verdad en vez de 2 o 3 copias
que corren el riesgo de divergir con el tiempo.
El pipeline de .parse() de parser-md
< Arquitectura interna de parser-md
ParserMd.parse(markdown) (parser.ts, ~200 líneas) es la única función que le importa a un
consumidor, pero por dentro es una cadena de pasos bien separados. Esta página recorre esa cadena en
orden real.
1. Separar frontmatter
parseFrontmatter(markdown) corta el bloque ---\n...\n--- inicial (si existe) del resto del
cuerpo -- ver Frontmatter y parseKeyValueLines. De acá en adelante,
frontmatter y body viajan por separado: el frontmatter nunca se tokeniza como Markdown, y el
body nunca vuelve a mirarse en busca de metadatos.
2. extendMeta -- una pasada por extensión, antes de tocar el cuerpo
Cada extensión registrada (en el orden de .use()) recibe el frontmatter crudo y puede escribir
en meta. Corre completo ANTES de tokenizar el body a propósito: así ninguna extensión necesita
esperar a que el cuerpo esté parseado para saber, por ejemplo, si la página declaró
[title]/[description] -- son 2 fases independientes, no una sola pasada mezclada.
3. Tokenizar + applyTokenTransforms
engine.lex(body) (MarkedEngine por debajo, ver
Decisiones de diseño) produce la lista de tokens de
marked -- siempre plana: un heading nunca aparece "dentro" de otro en el árbol de tokens, sin
importar qué tan anidado se vea el Markdown fuente. Esa lista pasa, token por token, por
applyTokenTransforms: primero transformCallout (del núcleo, convierte > [!TIP] en HTML de
aviso), después el transformToken de cada extensión instalada, en cadena, en el orden en que se
registraron -- cada una ve el token ya transformado por la anterior.
4. Extraer headings
Sobre esa misma lista (ya transformada) se recorre una vez más, tomando solo los tokens
type === 'heading', para armar la lista plana DocPageHeading[] -- el id de cada uno sale de
getHeadingId(), que cachea por identidad del token en un WeakMap (ver
Decisiones de diseño para el porqué).
5. buildSectionedSegments -- fabricar la jerarquía
Acá es donde el árbol de secciones se arma de verdad, porque los tokens NO vienen anidados (paso 3). El algoritmo recorre la lista top-level:
- Si el token es un
headingde nivelN: busca hacia adelante hasta el próximo heading de nivel<= N(o el final de la lista) -- todo lo que quedó en el medio es "el contenido de esta sección". Ese contenido se vuelve a procesar con la MISMA función, recursivamente -- así un###que cae dentro de ese rango termina comoSectionSegmentHIJO real del##, no como hermano. El resultado es unSectionSegmentconid/level/titleHtml(el heading renderizado solo, víaengine.render([heading])) ysegments(el resultado de la recursión). - Si el token NO es un heading (contenido antes del primer heading, o una página sin ninguno): se
junta el tramo contiguo sin heading y se delega a
buildSegments(paso 6).
6. buildSegments -- agrupar prosa, delegar a extensiones
Dentro de cada tramo sin headings (el contenido de una sección, o el tramo inicial de la página),
buildSegments recorre token por token:
- Primero prueba cada extensión (
tryBuildExtensionSegment) -- si alguna reclama el token víabuildSegment(típicamente un fence con unlangreservado), ese token se vuelve su propio segmento y CUALQUIER prosa acumulada hasta ahí se cierra (flushProseGroup) como unHtmlSegmentaparte -- así un párrafo, un fence de imagen, y otro párrafo quedan como 3 segmentos distintos, no mezclados en un solo bloque de HTML. - Caso especial: listas con código anidado. Si el token es una lista y
hasNestedCodedetecta un bloque de código en cualquier profundidad (dentro de un item, dentro de un item anidado, etc.), la lista completa se convierte en unListSegmentcuyositemsson, cada uno,buildSegments(item.tokens)recursivo -- esto es lo que permite que un fence```code-blockDENTRO de un item de lista siga siendo interceptado porparser-md-code-blockcomo su propio segmento, en vez de quedar atrapado como HTML crudo dentro del<li>(que es lo que haría el render por default demarkedpara una lista completa). - Todo lo demás se acumula como prosa en
proseGroup, hasta que algo lo corta (un segmento de extensión, una lista con código, o el final del tramo) -- en ese punto se renderiza junto (engine.render(proseGroup)) como un únicoHtmlSegment. Es deliberado: dos párrafos seguidos sin nada en el medio no necesitan 2 llamadas a render ni 2 segmentos, uno alcanza.
El resultado
{ segments, headings, meta } -- segments ya tiene la jerarquía real de secciones (paso 5), con
prosa agrupada y fences reclamados por extensiones adentro de cada una (paso 6); headings es la
lista plana calculada en el paso 4, con los mismos id que ya quedaron en los SectionSegment del
paso 5 (gracias al WeakMap del paso 4). El consumidor nunca necesita re-tokenizar ni re-calcular
nada de esto -- todo el trabajo de estructura ya está hecho.
Ecosistema de parser-md
Relación con el ecosistema de parser-md
@ngx-docs-markdown-kit/parser-md es el núcleo -- todo lo demás en el kit depende de él, nunca al
revés. parser-md no importa, no conoce, y no tiene ninguna dependencia (ni en tiempo de compilación
ni en tiempo de ejecución) de ninguno de los paquetes que lo consumen -- es lo que hace posible que
el punto de extensión (.use()) sea real y no solo nominal: cualquiera puede escribir una extensión
nueva sin tocar este paquete ni esperar que lo "sepa" de antemano.
Quién lo consume hoy
parser-md-seo-- extrae metadatos de SEO del frontmatter (meta) y expone servicios de Angular (meta tags, JSON-LD) + funciones (sitemap.xml/robots.txt) a partir del mismoContentManifestque genera este paquete.parser-md-code-block-- extensión que reconoce fencescode-block/card-code-blocky los renderiza con componentes de Angular.parser-md-image-- extensión que reconoce un fenceimage(src/alt/align/width/caption, parseado conparseKeyValueLinesde este mismo paquete) y lo renderiza como figura enriquecida.parser-md-card-- extensión que reconoce un fencecontent-cardy lo renderiza con<mat-card>real de Angular Material.parser-md-converter-- genera unREADME.mdmulti-flavor (GitHub/npmjs/NuGet) a partir delContentManifestde un sitio; depende deparser-mden tiempo de ejecución para reusarparseKeyValueLinesen vez de reimplementar el formatoclave: valorpor su cuenta.create-ngx-docs-site-- el consumidor final: el CLI que genera un sitio completo instalaparser-mdmás las extensiones que necesite, y las encadena con.use()en su plantilla (core/).
Todas las extensiones anteriores declaran @ngx-docs-markdown-kit/parser-md como dependencia real
(^0.0.1) e implementan ParserMdExtension importando sus tipos (DocPageSegment,
ParserMdContext, etc.) desde este paquete -- nunca definen su propia versión de esos contratos.
Uso real combinado (create-ngx-docs-site)
Ninguna extensión se usa sola en producción -- un sitio real las encadena todas:
const parser = createParser()
.use(seoExtension())
.use(codeBlockExtension())
.use(cardsExtension())
.use(imageExtension())
.use(contentCardExtension());El orden importa para transformToken/extendMeta (se aplican todos, en cadena, en ese orden) y
decide un eventual empate en buildSegment (la primera que reclama un token gana) -- ver
El orden de .use() importa para el detalle completo.
La dependencia cruzada: parser-md-card → parser-md-image
No todas las dependencias del ecosistema apuntan hacia este núcleo -- parser-md-card depende
además, como dependencia real de paquete, de parser-md-image (para reusar su lógica de imagen
dentro de la card). Es la única dependencia cruzada ENTRE extensiones hoy: todas las demás solo
dependen de parser-md, nunca entre sí.
El manifiesto compartido
buildContentManifest() (@ngx-docs-markdown-kit/parser-md/manifest, ver
El manifiesto de contenido) es la única fuente de verdad sobre la
estructura de contenido de un sitio -- la navegación y el BreadcrumbList de create-ngx-docs-site,
el sitemap.xml de parser-md-seo, y el README.md de parser-md-converter leen exactamente el
mismo árbol de pages/groups, calculado UNA sola vez a partir de la misma carpeta
public/content-parser-md/. Ningún consumidor mantiene su propio escaneo aparte -- si tuvieran cada
uno el suyo, nada garantizaría que la navegación, el sitemap y el README describieran la misma
estructura de páginas.
Parser MD de parser-md
REGRESAR A APARTADOS DE parser-md
parser-md es el nucleo del ecosistema: un parser de Markdown extensible, construido sobre
marked, sin ninguna dependencia de Angular. Cada libreria del kit (SEO, bloques de codigo,
imagenes, tarjetas) es una extension real que se registra sobre este mismo parser via su punto de
extension (.use()) -- no un fork, no una reimplementacion paralela.
Por que un nucleo sin Angular
Separar el parseo del render deja el motor de Markdown testeable y reusable sin levantar un
proyecto Angular completo -- el mismo manifiesto de contenido que arma parser-md (frontmatter,
jerarquia de carpetas, encabezados) lo consume tanto el navegador (barras de navegacion, on-this-
page) como scripts de build plano (sitemap.xml, README multi-flavor), corriendo con node sin
bundler.
Que trae de base
Frontmatter tipado con slugs SIEMPRE explicitos (nunca adivinados del nombre de archivo/carpeta),
manifiesto de contenido navegable con cards autogeneradas por grupo, callouts (> [!TIP],
> [!WARNING], etc.) y slugify de encabezados listo para anclas/on-this-page. Todo lo demas
(SEO, bloques de codigo, imagenes, tarjetas de contenido) es opcional y vive en su propio paquete.
