npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dom-md

v0.1.0

Published

Render DOM into readable Markdown.

Readme

dom-md

dom-md renders the DOM already on screen as readable Markdown.

import { renderMarkdown } from 'dom-md';

const markdown = renderMarkdown();

The DOM remains the source of truth. Start with semantic HTML and accurate ARIA, then add data-md or plugins where the visual UI needs a clearer text form.

Render a page or region

With no root, renderMarkdown() uses the first data-md-root, then main, then document.body.

renderMarkdown();
renderMarkdown(document.querySelector('[data-request-panel]'));

const parsed = new DOMParser().parseFromString(html, 'text/html');
renderMarkdown(parsed);

Use a renderer when several call sites share configuration:

import { createMarkdownRenderer } from 'dom-md';
import { budget, frontmatter } from 'dom-md/plugins';

const renderer = createMarkdownRenderer({
  plugins: [
    budget({ maxChars: 20_000 }),
    frontmatter(() => ({ title: document.title, url: location.href })),
  ],
});

const tree = renderer.toTree(document.body);
const markdown = renderer.stringify(tree);

// Pure tree serialization:
renderer.stringify(tree);

// Full render, including terminal postprocess plugins:
renderer.render(document.body);

The tree is a small Markdown tree that is compatible with the MDAST shapes dom-md emits. stringify(tree) stays pure; render(root) also applies terminal postprocess plugins.

DOM attributes

Exclude interface chrome:

<button data-md-ignore aria-label="Close"><CloseIcon /></button>

Replace a region with authored Markdown:

<section data-md={'## Consumption\n\n- Compute: 42 GB-hours'}>
  <UsageChart />
</section>

data-md is trusted as raw Markdown and emitted as-is. Put a children token on its own line to splice live descendants between raw Markdown chunks:

<section data-md={'## Request\n\n{{children}}'}>
  <p>Status: 200</p>
</section>

Use data-md-children-token to choose another token, data-md-max-chars for a local cap, and data-md-root to select the default capture root.

Default behavior

The defaults cover semantic text, headings, lists, links, code, native and ARIA tables, live form values, common ARIA widget state, buttons, images, labeled SVGs, canvases, hidden content, and ignored content. Password values are marked as omitted and hidden inputs are skipped.

Text content gets contextual minimal escaping: characters that would change Markdown structure (backticks, brackets, asterisks, emphasis-forming underscores, and paragraph-leading heading, list, or blockquote markers) are escaped, while ordinary prose such as snake_case stays untouched. Markdown provided through data-md or rawMarkdown nodes is never escaped.

Plugins

Callers pass one plugins list. A plugin is made of steps, and each step runs in one phase:

type MarkdownPlugin =
  | AnnotateElementPlugin
  | RenderElementPlugin
  | TransformPlugin
  | PostprocessPlugin;

Array order matters inside a phase. A user plugin with the same phase and id replaces that default. The default renderElement IDs are data-md, visuals, tables, aria-widgets, forms, and buttons; max-chars runs as a default annotate/transform pair. To disable a default, replace it with a step that always passes:

const markdown = renderMarkdown(root, {
  plugins: [{ id: 'tables', phase: 'renderElement', render: () => undefined }],
});

Most code should author plugins with createPlugin().

Render an element

Render element plugins return one or more Markdown tree nodes, null to omit, or undefined to continue to the next renderer/default.

import { createPlugin } from 'dom-md/plugins';
import { paragraph, text } from 'dom-md/builders';

const compactButtons = createPlugin('buttons', ({ renderElement }) => [
  renderElement((element) => {
    if (element.tagName !== 'BUTTON') return undefined;

    const label =
      element.getAttribute('aria-label') ??
      element.textContent?.trim() ??
      'Unlabeled action';

    if (label === 'Copy') return null;
    return paragraph([text(`Action: ${label}`)]);
  }),
]);

The context provides:

  • children(parent?) — extracted child nodes
  • inline(parent?) — extract child content as inline nodes for a paragraph or heading
  • extract(element) — extract another element through the full pipeline
  • renderDefault() — render the current element through later plugins and built-ins

Most render plugins use inline(), children(), or extract(). renderDefault() is available for rare cases that intentionally build on the default output for the same element.

Annotate an element

Use annotateElement() when a DOM attribute should become typed metadata for a later transform, without changing how the element renders.

Annotations are merged onto the Markdown nodes emitted for that element. Read the metadata later from node.data. For wrapper elements, the emitted nodes may come from descendants.

import { createPlugin } from 'dom-md/plugins';
import { visit } from 'dom-md/tree';

type ViewportData = {
  viewport?: {
    visible: boolean;
  };
};

const viewport = createPlugin<ViewportData>('viewport', ({ annotateElement, transform }) => [
  annotateElement((element) => {
    if (!element.hasAttribute('data-offscreen')) return undefined;
    return { viewport: { visible: false } };
  }),

  transform((tree) => {
    visit(tree, (node) => {
      if (node.data?.viewport?.visible === false && node.type === 'paragraph') {
        node.children = [text('Offscreen content omitted')];
      }
    });
  }),
]);

Transform the tree

Transforms receive the Markdown tree Root. They may mutate it and return nothing, or return a replacement root. context.stringify(node) uses the renderer's canonical Markdown serializer, which makes measurements reliable.

import { heading, text } from 'dom-md/builders';
import { createPlugin } from 'dom-md/plugins';

function generatedTitle(title: string) {
  return createPlugin('generated-title', ({ transform }) => [
    transform((tree) => {
      tree.children.unshift(heading(1, [text(title)]));
    }),
  ]);
}

Built-in plugins include budget(), outline(), frontmatter(), and redactSecrets(). Frontmatter inserts a YAML node; it is not a string postprocessor. Secret redaction combines a render step, a tree transform, and a final string postprocess pass — entirely on the public plugin API, so your app could ship the same plugin in userland.

redactSecrets() is opt-in and redacts common token formats plus credentialed URL passwords before Markdown leaves a capture surface:

import { redactSecrets } from 'dom-md/plugins';

const markdown = renderMarkdown(document.body, {
  plugins: [
    redactSecrets({
      patterns: [/tenant_secret_[a-z0-9]+/g],
      replacement: (value) => `[REDACTED:${value.length}]`,
    }),
  ],
});

Add data-md-redact when markup knows a field is sensitive even if it does not match a built-in pattern:

<span data-md-redact>internal-only-value</span>

Use visit() from dom-md/tree when a transform needs to inspect every node:

import { visit } from 'dom-md/tree';
import { createPlugin } from 'dom-md/plugins';

const markFields = createPlugin('mark-fields', ({ transform }) => [
  transform((tree) => {
    visit(tree, (node) => {
      if (node.data?.domMd?.kind === 'field') {
        node.data.app = { important: true };
      }
    });
  }),
]);

Postprocess the final string

Postprocess plugins run only during render(), after the Markdown tree has already been serialized. Use them for terminal string concerns such as wrapping, transport framing, or final redaction:

import { createPlugin } from 'dom-md/plugins';

const xmlEnvelope = createPlugin('xml-envelope', ({ postprocess }) => [
  postprocess((markdown) =>
    `<page-markdown>\n${markdown}\n</page-markdown>`),
]);

Tree interoperability

The public tree is MDAST-compatible, with a small rawMarkdown extension for trusted authored Markdown and optional provenance under node.data.domMd:

type DomMdMetadata = {
  authored?: boolean;
  chunk?: boolean;
  kind?: 'action' | 'field' | 'override' | 'visual';
  label?: string;
  sourceId?: string;
};

This works directly with unist-util-visit and with Remark plugins that operate on the node shapes dom-md emits. dom-md itself does not host a unified runtime because its primary job is DOM interpretation, not Markdown parsing.

For example, a Remark plugin can be adapted into the same plugin list without making unified part of the renderer runtime. Install the Remark/unified packages in your app if you want this adapter:

import remarkToc from 'remark-toc';
import { unified } from 'unified';
import type { MdastRoot } from 'dom-md';
import { createPlugin } from 'dom-md/plugins';

const tocProcessor = unified().use(remarkToc);
const toc = createPlugin('remark-toc', ({ transform }) => [
  transform((tree) =>
    tocProcessor.runSync(tree as never) as MdastRoot),
]);

The renderer validates supported Markdown tree content models after extraction, after each transform, and before public stringification. A malformed plugin throws a DomMarkdownError with the phase and plugin id. If a plugin needs to emit Markdown syntax outside dom-md's tree model, use a rawMarkdown node.

Next.js

dom-md/next provides createMarkdownRoute() for a same-origin catch-all Route Handler. The adapter forwards cookie and authorization headers only to the application origin, rejects cross-origin redirects, bounds response size and fetch time, marks output noindex, and defaults to private, no-store.

import { renderMarkdown } from 'dom-md';
import { createMarkdownRoute } from 'dom-md/next';
import { DOMParser } from 'linkedom';

export const GET = createMarkdownRoute(
  ({ document }) => renderMarkdown(document),
  { parser: new DOMParser() },
);

Entry points

| Entry point | Purpose | | --- | --- | | dom-md | Rendering, Markdown tree access, and plugin contracts | | dom-md/plugins | Built-in plugins and the plugin authoring API | | dom-md/builders | Small Markdown tree builders | | dom-md/tree | Markdown tree traversal helpers | | dom-md/md | String helpers for authoring data-md values | | dom-md/devtools | Inspection, provenance, audit, and browser panel | | dom-md/next | Next.js route adapter |

API

renderMarkdown(root?, options?): string;
toMarkdownTree(root?, options?): MarkdownTree;
stringifyMarkdown(tree, options?): string;

createMarkdownRenderer(options?): {
  render(root?): string;
  toTree(root?): MarkdownTree;
  stringify(tree): string;
};