How to Build a Markdown-It Collapsible List Plugin with details/summary for API Documentation

Markdown renderers for developer documentation often need to convert nested unordered lists into collapsible parameter panels. This article explains how a Markdown-It plugin reconstructs Tokens to transform ul/li into details/summary, while preserving SSR compatibility, browser search behavior, and readability. Keywords: Markdown-It, details/summary, API documentation.

The technical specification snapshot clarifies the implementation scope

Parameter Description
Core Language TypeScript / JavaScript
Markdown Engine Markdown-It
Target Protocols / Standards HTML, CommonMark, DOM
Core Rendering Elements details, summary, ul, li
Applicable Scenarios API documentation, parameter references, mind-map-style documents
Code Complexity Focus Token reconstruction, hierarchy matching, insertion order control
Star Count Not provided in the source
Core Dependency markdown-it

This plugin solves the collapsible presentation problem in developer documentation

In API documentation, parameter lists are naturally tree-structured, but native Markdown only provides unordered list semantics. Rendering them directly as ul/li works, but readability suffers, especially with deep nesting and descriptive text.

The core goal of this plugin is to convert li nodes into native details/summary structures, but only when a list item actually contains a child list. This preserves the Markdown authoring workflow while delivering a parameter-folding experience similar to OpenAI or Claude API documentation.

The mapping between the source list and the target structure is straightforward


<ul>

<li>1</li>

<li>
    parent

<ul>

<li>child</li>
    </ul>
  </li>
</ul>

This structure expresses a clear parent-child hierarchy, which makes it a natural input shape for collapsible panels.

The transformation rules should be grounded in HTML semantics

The plugin does not invent a custom collapsible widget. Instead, it relies on the browser-native details and summary elements. This approach provides three major benefits: no manual state management, no injected click handlers in SSR scenarios, and automatic expansion when browser search matches hidden content.

As a result, the rules can be reduced to three points: convert an li with a direct ul child into details; wrap the content from the start of the li up to the child ul in summary; and keep the child ul as the expandable content.

The target DOM structure can be represented as follows


<details>

<summary>parent</summary>

<ul>

<li>child</li>
  </ul>
</details>

This structure turns a tree-shaped list into a browser-native collapsible panel.

Handling everything at render time is possible but costly to maintain

If you only modify renderer.rules, you can intercept bullet_list_open, list_item_open, and list_item_close. The idea is to inspect neighboring levels in the linear Token stream and decide whether the current node should emit `

` or ``. The problem is that `summary` is not a simple tag replacement. It wraps a range of existing content. In a linear Token model, that means stitching opening and closing tags across multiple nodes, which makes the rendering logic harder to reason about and maintain. ### A typical render-time rule looks like this “`ts mdIt.renderer.rules.list_item_open = (tokens, idx) => { const current = tokens[idx]; for (let i = idx + 1; i “; // Emit the opening collapse tags when a nested list is detected } } return ”
  • “; // Keep regular list items unchanged }; “` This code checks whether the current `li` contains a direct child `ul` when rendering the opening list-item tag. If it does, it emits `details + summary`. ## Rebuilding Tokens during parsing is the more robust implementation path A more maintainable approach is to post-process the Token stream after Markdown-It completes its base parse. This lets you detect directives such as `@bullet-summary` first, then decide whether a given list should become collapsible, instead of repeatedly scanning backward during rendering. The key idea is not to introduce new syntax, but to reorganize existing Tokens: hide the directive block, locate the target `ul` open/close range, replace `li` with `details` in batch, and insert the corresponding `summary` Tokens. ### First detect the directive and trigger the reconstruction flow “`ts if ( token.content === “@bullet-summary” && nextToken?.type === “paragraph_close” && nextStep2Token?.type === “bullet_list_open” ) { prevToken && (prevToken.hidden = true); // Hide the paragraph opening token token.hidden = true; // Hide the directive text token.children = []; // Clear child nodes to avoid rendering leftovers nextToken.hidden = true; // Hide the paragraph closing token rebuildUlTokens(state, i + 2); // Rebuild the structure starting from the target ul } “` This code removes the marker directive from the final output and hands the following list to the collapsible reconstruction logic. ## Locating open/close ranges requires explicit stack-depth tracking Markdown-It uses a linear Token stream, so unlike a DOM tree, you cannot traverse parent-child relationships directly through pointers. You must find the target range by pairing `open` and `close` Tokens with explicit depth counting. The worst-case complexity is usually `O(n)`, but the approach is reliable. Because `token.level` may no longer remain absolutely reliable after the plugin inserts new Tokens, it is still safer in practice to maintain an explicit `level` variable or stack-depth counter rather than depend entirely on existing fields. ### Example implementation for finding the matching close node “`ts function findClose(tokens: Token[], openIdx: number) { const openToken = tokens[openIdx]; const closeType = openToken.type.slice(0, -5) + “_close”; let level = 1; for (let i = openIdx + 1; i b.idx – a.idx) // Apply changes from higher indexes to lower indexes .forEach(action => { tokens.splice(action.idx, 0, action.token); // Commit in batch to preserve index stability }); “` This code defers Token insertion and applies changes in reverse order so indexes remain stable. ## The styling layer should focus on cross-browser consistency, not complex interaction The default styling of `details/summary` is not consistent across browsers, so production use usually requires a thin CSS normalization layer. This matters even more when `summary` contains multiline descriptions, because borders, spacing, and marker styles need to be aligned. For documentation systems, the styling should remain restrained. Emphasize hierarchy, click targets, and expansion state, but avoid introducing heavyweight interaction state management. ### Minimal viable styling example “`css details { display: block; /* Normalize block-level rendering for details across major browsers */ } summary { display: list-item; /* Preserve list semantics and a visible marker */ border-bottom: 1px solid #e5e7eb; /* Improve visual separation for multiline headings */ cursor: pointer; /* Clearly signal that the element is expandable */ } “` This CSS provides a minimal consistency baseline for native collapsible elements. ## This type of plugin is especially well suited to API parameter trees and structured reference docs From a documentation-engineering perspective, the value of this approach is not just visual polish. It allows Markdown authoring, developer reading experience, and AI-consumable structure to coexist. The author also notes that documentation will likely continue to serve both humans and AI for the foreseeable future. If you continue to optimize this design, you can combine parse-time markers with render-time tag output to reduce the complexity of direct Token rewriting. A further step would be to introduce a unified change-orchestration strategy so multiple insert operations do not interfere with one another. ## FAQ: The three questions developers care about most ### 1. Why not just use a frontend component library to build an accordion? Because `details/summary` natively supports expansion state, is SSR-friendly, requires no hydration, and can auto-expand when browser search matches collapsed content. That makes it a better fit for static or semi-static documentation sites. ### 2. Why rebuild during parsing instead of only changing `renderer.rules`? Because `summary` wraps a content range rather than replacing a single node. Parse-stage post-processing gives you much better control over scope, insertion order, and directive recognition, which significantly improves maintainability. ### 3. Will this approach affect other Markdown-It plugins? Potentially yes, especially plugins that depend on `token.level`, node order, or tag types. After reconstruction, you should realign `level` values as needed and keep structural changes centralized in the post-processing stage whenever possible. Core takeaway: This article reconstructs a Markdown-It collapsible unordered-list plugin and explains how to convert nested `ul/li` structures into native `details/summary`. It also compares render-time and parse-time implementation strategies for building a better parameter-folding experience in API documentation.