Implementando Listas Colapsáveis em Markdown-it com Elementos Nativos HTML5

A documentação técnica, especialmente para APIs, evoluiu para exigir formatos que atendam tanto desenvolvedores quanto modelos de IA. O Markdown consolidou-se como o padrão para essa produção e consumo. Para melhorar a leitura de parâmetros aninhados, similar ao que vemos em documentações modernas de grandes provedores de IA, podemos transformar listas não ordenadas padrão em painéis colapsáveis.

Em vez de criar componantes complexos com JavaScript para gerenciar estados de "aberto/fechado", podemos utilizar as tags nativas <details> e <summary>. Isso elimina a necessidade de hidratação de eventos no SSR, reduz a complexidade do DOM e permite que a busca nativa do navegador expanda automaticamente os itens que contêm o termo pesquisado.

Análise da Estrutura HTML

Para entender a transformação, precisamos analisar como o Markdown processa listas aninhadas. A estrutura básica de entrada é:

* Item Raiz
* Parâmetro A
  * Sub-parâmetro A.1
  * Parâmetro B
    * Sub-parâmetro B.1
    * Sub-parâmetro B.2
      * Detalhe B.2.1

O resultado padrão em HTML gerado pelo parser é:

<ul>
  <li>Item Raiz</li>
  <li>Parâmetro A
    <ul>
      <li>Sub-parâmetro A.1</li>
      <li>Parâmetro B
        <ul>
          <li>Sub-parâmetro B.1</li>
          <li>Sub-parâmetro B.2</li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

Regras de Transformação

O objetivo é converter essa estrutura linear em uma hierarquia de componentes expansíveis. As regras de mapeamento são:

  • O elemento <ul> atua como o container do conteúdo expandido, recebendo uma classe específica para estilização.
  • Qualquer elemento <li> que contenha um <ul> filho direto deve ser convertido em um elemento <details>.
  • O conteúdo textual do <li> que precede o <ul> filho deve ser envolvido por um elemento <summary>, servindo como o cabeçalho clicável.

Implementação: Fase de Renderização

A abordagem mais imediata é interceptar as regras de renderização do markdown-it. Podemos modificar a saída dos tokens de abertura e fechamento de listas e itens.

const customRenderer = (md) => {
  const originalListOpen = md.renderer.rules.bullet_list_open || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));

  md.renderer.rules.bullet_list_open = (tokens, idx) => {
    const currentToken = tokens[idx];
    for (let searchIdx = idx - 1; searchIdx >= 0; searchIdx--) {
      const prevToken = tokens[searchIdx];
      if (prevToken.level < currentToken.level - 1) break;
      if (prevToken.type === 'list_item_open' && prevToken.level === currentToken.level - 1) {
        return '<ul class="collapsible-nested-group">';
      }
    }
    return originalListOpen(tokens, idx, md.options, {}, md.renderer);
  };

  md.renderer.rules.list_item_open = (tokens, idx) => {
    const currentToken = tokens[idx];
    for (let searchIdx = idx + 1; searchIdx < tokens.length; searchIdx++) {
      const nextToken = tokens[searchIdx];
      if (nextToken.level <= currentToken.level) break;
      if (nextToken.type === 'bullet_list_open' && nextToken.level === currentToken.level + 1) {
        return '<details class="collapsible-item"><summary>';
      }
    }
    return '<li>';
  };

  md.renderer.rules.list_item_close = (tokens, idx) => {
    const previousToken = tokens[idx - 1];
    if (previousToken && previousToken.tag === 'ul') return '</details>';
    return '</li>';
  };
};

Embora funcional, essa abordagem apresenta limitações quando precisamos aplicar a transformação condicionalmente. Se utilizarmos uma diretiva personalizada (como @collapsible-list) para ativar o comportamento apenas em blocos específicos, a fase de renderização exige uma travessia ascendente custosa no array de tokens, pois a estrutura é plana.

Implementação: Fase de Aálise (Core)

Para resolver o problema da diretiva condicional, a manipulação deve ocorrer na fase de processamento central, modificando a árvore de tokens antes da renderização. Isso nos permite localizar a diretiva, ocultá-la e reeestruturar os tokens subsequentes de forma segura.

const createCollapsiblePlugin = (directiveTag) => {
  return (markdownInstance) => {
    markdownInstance.core.ruler.push('collapse_nested_lists', (state) => {
      const tokenStream = state.tokens;
      const pendingMutations = [];

      for (let cursor = 0; cursor < tokenStream.length; cursor++) {
        const currentToken = tokenStream[cursor];

        if (currentToken.type === 'inline' && currentToken.content.trim() === directiveTag) {
          const paragraphClose = tokenStream[cursor + 1];
          const listOpen = tokenStream[cursor + 2];

          if (paragraphClose?.type === 'paragraph_close' && listOpen?.type === 'bullet_list_open') {
            tokenStream[cursor - 1].hidden = true;
            currentToken.hidden = true;
            currentToken.children = [];
            paragraphClose.hidden = true;

            const listEndIndex = locateClosingTag(tokenStream, cursor + 2);
            if (listEndIndex > -1) {
              restructureListBlock(state, tokenStream, cursor + 2, listEndIndex, pendingMutations);
            }
          }
        }
      }

      pendingMutations
        .sort((a, b) => b.position - a.position)
        .forEach(mutation => tokenStream.splice(mutation.position, 0, mutation.payload));
    });

    function locateClosingTag(stream, startIndex) {
      const targetType = stream[startIndex].type;
      const closingType = targetType.replace(/_open$/, '_close');
      let nestingLevel = 1;

      for (let step = startIndex + 1; step < stream.length; step++) {
        if (stream[step].type === targetType) nestingLevel++;
        else if (stream[step].type === closingType) {
          if (--nestingLevel === 0) return step;
        }
      }
      return -1;
    }

    function restructureListBlock(state, stream, openPos, closePos, mutations) {
      stream[openPos].attrJoin('class', 'nested-collapsible-wrapper');
      const closeTokenStack = [];

      for (let pos = closePos; pos >= openPos; pos--) {
        const node = stream[pos];
        if (node.type === 'list_item_close') closeTokenStack.push(node);
        if (node.type === 'list_item_open') {
          const pairedClose = closeTokenStack.pop();
          injectCollapsibleElements(state, stream, pos, pairedClose, mutations);
        }
      }
    }

    function injectCollapsibleElements(state, stream, itemOpenIdx, itemCloseToken, mutations) {
      let childListPosition = -1;
      let depthTracker = 0;

      for (let walkIdx = itemOpenIdx; walkIdx < stream.length; walkIdx++) {
        const walkToken = stream[walkIdx];
        depthTracker += walkToken.nesting;

        if (depthTracker === 2 && walkToken.type === 'bullet_list_open') {
          childListPosition = walkIdx;
          break;
        }
        if (depthTracker <= 0) break;
      }

      if (childListPosition !== -1) {
        const openItemToken = stream[itemOpenIdx];
        openItemToken.type = 'collapsible_details_open';
        openItemToken.tag = 'details';

        if (itemCloseToken) {
          itemCloseToken.type = 'collapsible_details_close';
          itemCloseToken.tag = 'details';
        }

        const summaryOpen = new state.Token('collapsible_summary_open', 'summary', 1);
        const summaryClose = new state.Token('collapsible_summary_close', 'summary', -1);

        summaryOpen.level = openItemToken.level + 1;
        summaryClose.level = openItemToken.level + 1;

        for (let innerIdx = itemOpenIdx + 1; innerIdx < childListPosition; innerIdx++) {
          stream[innerIdx].level = (stream[innerIdx].level || 0) + 1;
        }

        mutations.push({ position: itemOpenIdx + 1, payload: summaryOpen });
        mutations.push({ position: childListPosition, payload: summaryClose });
      }
    }
  };
};

A estratégia de coletar mutações e aplicá-las em ordem reversa é crucial. Inserir tokens diretamente durante a iteração alteraria o comprimento do array, invalidando os índices subsequentes e quebrando a lógica de travessia.

Estilização CSS

Como o comportamento padrão dos elementos <details> e <summary> varia entre os navegadores, é necessário aplicar uma normalização básica e definir a estética do componente.

/* Normalização para elementos de colapso */
.nested-collapsible-wrapper {
  display: block;
  margin: 0;
  padding: 0;
  list-style: none;
}

details.collapsible-item {
  display: block;
  border: 1px solid #e2e8f0;
  border-radius: 4px;
  margin-bottom: 8px;
}

summary {
  display: list-item;
  cursor: pointer;
  font-weight: 600;
  padding: 8px 12px;
  background-color: #f8fafc;
}

Tags: markdown-it html5-details ast-manipulation technical-docs javascript-plugins

Publicado em 7-18 04:31