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

hyper-morph

v1.6.3

Published

Three-way merge of HTML documents applied to a live DOM: word-level text merging, identity without ids, and a report of every decision

Readme

HyperMorph

Three-way merge of HTML documents, applied to a live DOM.

HyperMorph does one job: given the document a page started from (base), what this tab has now (local), and what arrived from a peer or from disk (remote), it produces the merged document and updates the live DOM to match it, keeping every live node that has a counterpart. Text merges word by word, elements keep their identity without ids, and every decision it made is reported back.

It exists so HTML files can save themselves and be edited by several people at once. It is the merge engine behind ClayJS.

Installation

npm install hyper-morph
import { mergeDocument, morphDocument, morphElement } from "hyper-morph";

A minified IIFE build is in dist/hyper-morph.min.js and exposes window.HyperMorph.

Three calls

// Three-way: base is the last document both sides agreed on.
const report = await mergeDocument({
  live: document,
  base: lastSyncedHtml,
  remote: incomingHtml,
});

// Two-way: no base; the live DOM is treated as the base, so the remote
// document lands as-is except where the live DOM has protected state.
await morphDocument(document, incomingHtml);

// One element, or its children only.
await morphElement(el, "<div>…</div>");
await morphElement(el, "<li>a</li><li>b</li>", { children: true });

All three mutate the DOM synchronously and return a Promise that resolves once inserted stylesheets and external scripts have loaded, with a report of what happened. Unknown options throw before any mutation.

What a merge does

  1. Parse. Strings become documents. A doctype in the remote document is copied to the live one.
  2. Align. Base is aligned with local and with remote, independently. Elements pair by identity (data-id, then id, or your own function), then by structure: identical subtrees pair in lockstep, then unique signatures, then signature plus similar text, then position. Unpaired elements on both sides are checked for moves across parents, and what is left pairs slot for slot when every unpaired element under a parent sits at the same index with the same tag on both sides. Nothing pairs on tag and class alone.
  3. Merge. Every base node is emitted with whatever each side changed: attributes per name (class as a token set, style as a declaration map), text as a word-level three-way merge, children ordered by the side that reordered them, insertions anchored to the sibling they followed. An edit or move on one side beats a delete on the other.
  4. Apply. The live DOM is walked against the merged tree. Live nodes are moved with moveBefore (state, focus and iframes survive), attributes and text are updated in place, and only nodes with no live twin are created. Focus and the caret are restored, mapped through the text merge.
  5. Scripts. Scripts that are new to the page run once, after apply.

Options

await mergeDocument({
  live: document, // the Document to update
  base: html, // string | Document | null (null = two-way)
  remote: html, // string | Document

  // The local side. Defaults to the live DOM. ClayJS passes a snapshot
  // clone plus a function mapping clone nodes to live nodes.
  local: { root: cloneRoot, toLive: (n) => liveFor(n) },

  // Identity per side: a function, or a path-keyed id map to import after
  // parsing. Default: data-id, then id.
  identity: { base: idOf, local: idOf, remote: { map, then: idOf } },

  // Never touched, never imported, never indexed. Ancestor-aware.
  ignore: (el) => el.hasAttribute("editor-ui"),

  // Local edits inside these regions do not count; remote lands unchanged.
  remoteWins: (el) => el.hasAttribute("no-dirty"),

  // Attributes left out of the merge and of every report.
  ignoreAttribute: (el, name) => name === "savestatus",

  conflicts: "remote", // "remote" | "local" | "both" (text only)
  protectFocusedValue: true, // keep the focused input's value
  formState: "attribute", // or "property" for script-built content
  head: {
    awaitLoads: false, // resolve after inserted stylesheets load
    preserve: (el) => false, // a live head child this approves is never removed
  },
  scripts: {
    execute: true, // run scripts new to the page after apply
    merge: true, // three-way merge of <script type=application/json merge=…>
    mergeTags: [], // extra recognizers for mergeable script tags
  },
  hooks: {
    beforeNodeAdded: (n) => {},
    afterNodeAdded: (n) => {},
    beforeNodeRemoved: (n) => {},
    afterNodeRemoved: (n) => {},
    beforeNodeMorphed: (live, merged) => {},
    afterNodeMorphed: (live, merged) => {},
    beforeAttributeUpdated: (name, el, kind) => {},
  },
  beforeApply: (mergedDoc) => {}, // inspect or serialize before apply
  fastPath: false, // narrow a clean tab's merge to the one changed branch (see docs/api.md)
});

Return false from a before* hook to veto that action. Note that beforeNodeMorphed and afterNodeMorphed disable the unchanged-subtree fast path, since the hook contract fires per matched node.

morphDocument(live, remote, options) and morphElement(el, content, options) take the same options minus live, base and remote. morphElement also takes children: true to merge the element's children only, and base (string or element) for a three-way merge of one element.

The report

type MergeReport = {
  applied: Applied[]; // what apply did to the live DOM
  decisions: Decision[]; // every merge decision that differs from base
  conflicts: Conflict[]; // both sides changed the same thing
  localDiverged: boolean; // merged output differs from remote: relay or save it
  identities: Array<[Element, string]>; // live elements paired with an identified remote element
  moved: Element[]; // live elements that changed parent
  replaced: Element[]; // nodes recreated because nothing live matched them
  stats: MergeStats; // per-apply counters: numbers only, never content
};

applied answers "what changed in this DOM" and matches what a MutationObserver would see. decisions answers "what did the merge decide", including local-only decisions that were already in the DOM. A consumer that mints its own element identities overwrites its record with each entry of identities, which is how ids converge across tabs after one round trip. stats is per-apply instrumentation: counters of what the merge had to do (lazyTwins, hashRejected, similarTiesStrict, similarTiesLoose, ambiguousMoves, certificationPairs, certificationVisited), whether the fast path was attempted and taken, and the named reason it fell back; never anything from the page.

Conflicts carry the base, local, remote and resolved values for text and attributes, and a detail for structure: both-reordered, both-moved, edit-beats-delete, move-beats-delete, insert-collision. Each also carries recovery: the unit's paths on every side, exact text offsets, the attribute's namespace, structural placements and a snapshot of the local fragment, whether the local operation was lost, and whether the result reached the live DOM (see docs/api.md).

Text merging

Text merges word by word, with a Myers diff of each side's words against base. Inside a paragraph, the text and its formatting and inline elements merge as one sequence, so edits to different words both land. Disjoint edits both apply; overlapping edits resolve by the conflicts policy and are reported. The focused element's caret is mapped through the merge, so typing while a remote edit lands in the same paragraph keeps the cursor where it was.

import { merge3Text } from "hyper-morph";
merge3Text("the lazy dog", "Note: the lazy dog", "the sleepy dog").text;
// "Note: the sleepy dog"

Elements whose text is code rather than prose (script, style, textarea, template, iframe, media) never pair by their text. Executable script text, textarea text and comments merge as a whole value: the side that changed wins, and both changing is a conflict. <style> text merges word by word like any other text.

A paragraph split or joined on one side while the other side edits it merges as one word sequence, so every word lands once; see "Paragraph splits and joins" in docs/api.md.

Identity without ids

Nothing in your markup needs an id. Alignment pairs by content, and it refuses to pair elements on tag and class alone: two <li>s pair only when their text is similar, their subtrees are identical, or they sit at the same index with nothing else to disambiguate. If you do have identities (an id, a data-id, or a synthetic map from a collaboration layer), they win.

Other exports

import {
  merge3, // the pure merge: (base, local, remote, options) => merged document + provenance
  merge3Text,
  diff, // word-level diff
  createIdentityStore,
  importMap,
  tieredIdentity, // synthetic identity helpers
  mergeJson,
  mergeScriptText, // three-way JSON merge for data script tags
  parseJsonRelaxed,
  parseRulesRelaxed,
  createParseCache, // one-deep per-lane parse cache
  findChangedRoots,
  spliceProtected, // protected string splice (also at hyper-morph/splice)
} from "hyper-morph";

Development

npm test            # Node (jsdom) unit tests + Chromium behavioral suite
npm run test:node
npm run test:fuzz  # structural fuzz, seeds 1 to 5000
npm run test:chrome
npm run perf        # CPU profile of a 3000-element page, clean and dirty tab
npm run build       # dist/hyper-morph.min.js

Documentation

  • docs/api.md: the contract. Every option, every report field, the merge and apply rules, errors, and the performance budget.
  • types/index.d.ts: type declarations, shipped with the package.
  • docs/rewrite-plan.md: the design record. The review of 0.5.x, how ClayJS uses the library, the ten scenarios the merge was built against, the specification, the ClayJS integration plan, and where the implementation refined the spec.
  • test/README.md: the test suites, the compat shim the inherited suites run through, and the perf harness.
  • CHANGELOG.md, CONTRIBUTING.md.

License

MIT-0. See LICENSE and THIRD-PARTY-NOTICES.md.