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

on_the_money

v0.5.3

Published

Opinionated, attribute-driven, standards-oriented modern framework. <2KB gzip. Native browser APIs only. See README.md for the authoring context.

Readme

on_the_money.js

This README is the authoring context for AI agents using on_the_money. Read top-to-bottom for a complete picture; everything you need to write an OTM app is here. No satellite docs.

Opinionated, attribute-driven, standards-oriented modern framework for the web. <2KB gzip. Native browser APIs only. ESNext.

UI signal state — theme, page, modal-open, form-error, the boolean and enum flags that drive visual transitions — lives in DOM attributes; structured data (lists of records, collections, async results) lives in JS like everywhere else. Reactivity comes from [data-text] and [data-i18n] selectors and from CSS attribute-selector rules. Events use one delegated listener per (parent, type) pair. Routing uses the History API. Localization uses Intl. There is no virtual DOM, no JSX, no transpilation step, no proprietary tooling. The framework is a thin layer of conventions over the platform.

Install

npm install on_the_money

Quickstart

A complete two-file app. index.html carries semantic structure, app.js carries behavior.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title data-i18n="app_title">My App</title>
  <link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.classless.min.css">
  <script type="module" src="./app.js"></script>
</head>
<body>
  <main>
    <h1 data-i18n="app_title">My App</h1>
    <p>Hello, <strong data-text="user">friend</strong>.</p>
    <button data-action="greet">Greet</button>
  </main>
</body>
</html>
// app.js
import { the, on } from "on_the_money";

await the.boot({
  dictionary: { app_title: "On The Money" },
});

on("main", "click", '[data-action="greet"]', () => {
  the("user", "Alice");
});

The framework rehydrates body data-* and [data-text] from localStorage on boot; subsequent the() writes both the attribute and persist to localStorage["otm:KEY"].

Pico Classless integration

Pico Classless is the recommended companion stylesheet. It styles semantic HTML directly — no classes, no JS. Include the stylesheet once:

<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.classless.min.css">

Then write semantic HTML (<main>, <nav>, <article>, <form>, <button>, <input> with <label>, etc.). on_the_money's conventions all use data-* and aria-* attributes, so they layer cleanly under Pico's element-targeted CSS.

Exports

import { on, the, _t, route, $, $$ } from "on_the_money";

Aliases on the: the.t === _t, the.route === route, the.form(formEl), the.flat(obj, sep?), the.match(pattern, path?), the.boot(options?). Live accessors: the.dictionary, the.locale. Live accessors on the: the.dictionary, the.locale.

API

on(parent, event, selector, fn) — event delegation

| Arg | Type | Notes | | --- | --- | --- | | parent | Element \| string \| null | If string, resolved via document.querySelector. If falsy, defaults to document.body. | | event | string | Any DOM event name ("click", "submit", "mounted", etc.). | | selector | string | CSS selector matched via event.target.closest(selector). | | fn | (event, target) => void | target is the element the selector matched. |

Returns an () => void unsubscribe function.

const off = on("#list", "click", "[data-action='delete']", (e, target) => {
  target.closest("[data-item]").remove();
});
off(); // detaches the listener

on.emit(el, event, detail)CustomEvent dispatch

Dispatches a bubbling, cancelable CustomEvent on el. el may be an element or a selector string.

on.emit("#cart", "items-changed", { count: 3 });

the(...) — state

Polymorphic on a single disambiguator: args[0] instanceof Element. Three call shapes per scope (get, set, batch).

| Call | Behavior | Returns | | --- | --- | --- | | the(key) | Read body data-KEY (or aria-mapped equivalent). | string \| null | | the(key, val) | Write body attribute + descendant [data-text="key"]. Persists to localStorage only if key is in persistKeys. | document.body | | the({ k: v, ... }) | Batch global write. | document.body | | the(el, key) | Read scoped attribute on el. | string \| null | | the(el, key, val) | Write scoped attribute on el + descendant [data-text="key"]. | el | | the(el, { k: v, ... }) | Batch scoped write. | el |

  • Key naming. Keys auto-convert to kebab-case for attribute writes and [data-text] lookups. the("chapterHasNav", x) writes data-chapter-has-nav. snake_case (chapter_has_nav) normalizes to kebab too. Single-word keys (theme, user) pass through unchanged. Round-trips via the platform's dataset API: el.dataset.chapterHasNav reads the same attribute. CSS selectors and [data-text="..."] slot values must use kebab-case to match what JS writes.
  • Persistence is opt-in. Default behavior writes attributes only — nothing persists to localStorage. Declare the.boot({ persistKeys: ["theme", "lang"] }) to opt specific keys in. The boot replay loads only those keys back. Common persistKeys for an i18n + theme app: ["theme", "lang"].
  • ARIA mapping (closed set, key → attribute): expanded, selected, hidden, checked, disabledaria-*. Criterion: HTML5 widget/form boolean states only. No future expansion. Other ARIA attributes (aria-invalid, aria-controls, aria-describedby, etc.) go through el.setAttribute("aria-...", val) like any other HTML attribute. Non-ARIA keys → data-* (after kebab conversion).
  • Dynamic <title> and other <head> slots: global the(key, val) writes walk the entire document for [data-text="key"] matches, not just body. Put <title data-text="title">Default</title> in <head> and the("title", "X") updates it.
  • Plain HTML attributes (href, value, rel, etc.) use el.setAttribute(name, val) directly. The framework deliberately doesn't wrap these — the() is for state; setAttribute() is for structure. Two distinct concerns, two distinct primitives.
  • Booleans coerce to "true"/"false" inside the setter. the(el, "checked", true) writes "true".
  • Values MUST be flat primitives. Pass nested objects through the.flat(...) first.
  • the(key, undefined) throws. Two args means set; missing val is a contract violation.

the.form(formEl) — form extraction

Walks input, select, textarea descendants. Skips unnamed, disabled, submit/button/reset controls, and unchecked checkboxes/radios. Parses bracket-notation names into a nested object:

the.form(form);
// <input name="user[name]" value="Alice">     → { user: { name: "Alice" } }
// <input name="tags[]" value="a">             → { tags: ["a", ...] }
// <input name="tags[]" value="b">

Returns a nested object (matches browser submission semantics). Compose with the.flat before feeding to the(el, {...}).

the.flat(obj, sep = "_") — nested-to-flat

the.flat({ user: { name: "Alice" }, tags: ["a", "b"] });
// → { user_name: "Alice", tags_0: "a", tags_1: "b" }

the.flat({ a: { b: 1 } }, ".");
// → { "a.b": 1 }

Throws on non-object input.

the.boot(options?) — explicit init

Importing the module does nothing. Call the.boot() at the consumer's entry point.

await the.boot({ signal, locales, dictionary, namespace, defaultLocale, persistKeys });

| Option | Type | Behavior | | --- | --- | --- | | signal | AbortSignal | Aborts the i18n fetch. | | locales | string | Override the <meta name="i18n" content> path. | | dictionary | object | Inline dictionary; skips the fetch entirely. | | namespace | string | Sets localStorage prefix to ${namespace}: (default otm:). Must be set before any state ops. | | defaultLocale | string | Locale your static HTML is already written in. When the resolved locale base matches this, the dictionary fetch and _t() hydration pass are skipped entirely — no network, no FOUC. Auto-detected from <html lang> if omitted. | | persistKeys | string[] | Global state keys that should persist to localStorage. Default: empty — nothing persists. Writes still update the body attribute and any [data-text] mirrors regardless. Boot replay rehydrates only these keys. Use for stable preferences: ["theme", "lang"]. Scoped state (the(el, ...)) never persists regardless. |

Boot sequence:

  1. Resolve locale: ?lang= query → localStorage["${prefix}lang"]document.documentElement.langnavigator.language"en". Writes the.locale. <html lang> outranks navigator.language — the server's deliberate language declaration wins over the browser's passive preference. If you want navigator-driven detection (static SPA with no server-side i18n), leave <html lang> empty or omit the attribute and the chain falls through to navigator.
  2. Short-circuit check. If resolved locale base matches defaultLocale (or <html lang> if not provided), skip steps 3 and 5. Static HTML already serves the right text.
  3. Resolve dictionary: inline dictionaryfetch(${path}/${target}.json) if <meta name="i18n"> or locales option is present. Falls back through full → base → data-fallback.
  4. Replay localStorage entries matching the prefix (except ${prefix}lang) back onto body data-* and [data-text].
  5. Run _t() to hydrate [data-i18n].

Writing data-i18n elements: always include the source-language text inside as a fallback:

<!-- good — SEO fallback, no-JS fallback, no-flash default -->
<h1 data-i18n="title">Welcome to the app</h1>

<!-- avoid — page is blank until hydration -->
<h1 data-i18n="title"></h1>

The framework preserves existing textContent when a dictionary key is missing, so source-language text inside data-i18n elements stays visible if _t() runs against an empty dictionary (the short-circuit case above, or any environment without the i18n fetch).

_t(key, options)Intl localization

_t("hello");                                       // string lookup in the.dictionary
_t("hello", { name: "Alice" });                    // {name} → "Alice"
_t("items", { qty: 5 });                           // Intl.PluralRules picks { one, other } entry
_t("price", { val: 9.99, type: "currency" });      // Intl.NumberFormat (USD)
_t("when", { val: Date.now(), type: "date" });     // Intl.DateTimeFormat

_t(node);                                          // hydrate every [data-i18n] inside node
_t();                                              // hydrate document.body

Missing dictionary keys preserve existing textContent (SEO fallback). When options.type is "currency" or "date", Intl formatting of options.val runs regardless of whether the dictionary has an entry — useful in default-locale apps that skip the fetch.

[data-i18n] element binding (always include source-language fallback text inside):

<span data-i18n="cart_items" data-i18n-qty="3">3 items</span>
<span data-i18n="price" data-i18n-val="9.99" data-i18n-type="currency">$9.99</span>

route(callback) — pushState router

route((pathname, search, hash) => {
  // render whatever fits this route
});

Fires the callback on initial mount, on popstate, on hashchange, and on intercepted internal <a> clicks. Skips interception for data-external, target="_blank", and cross-origin hrefs. Hash-only same-page links are left to the browser; hashchange still triggers the callback.

the.match(pattern, path?) — pattern matching with named segments

Express-style colon syntax. Extracts named segments from path (defaults to window.location.pathname). Decodes URI-encoded segments. Returns {name: value} on match, null on no match.

the.match("/@:user/:work/:chapter", "/@alice/great-work/chapter-1");
// → { user: "alice", work: "great-work", chapter: "chapter-1" }

the.match("/about", "/about");
// → {} (matched, no params)

the.match("/:slug", "/about/extra");
// → null (segment count doesn't match)

Scope: :name segments only. No optional segments, regex constraints, wildcards, or nested patterns. If your routing needs those, bring path-to-regexp or similar — the.match is the 80%-case helper, not a router framework.

$(context, selector) / $$(context, selector) — context-aware DOM query

$(el, ".child");        // first match (Element | null)
$$(el, ".child");       // all matches (Array)
$(".child");            // shorthand: context = document
$$(".child");

$.clone(parent, selector, options?) — template instantiation

const el = $.clone("#list", "#tmp");
const head = $.clone("#list", "#tmp", { position: "afterbegin" });   // prepend
const sib  = $.clone(anchorEl, "#tmp", { position: "beforebegin" }); // insert before anchor

Clones the first element of <template selector>, runs _t(el) for i18n hydration, inserts at position (default beforeend — append inside parent), dispatches a bubbling mounted CustomEvent (detail: { parent }) after insertion so the element is at its final position when the event fires. Returns the mounted element. Throws if parent/template is missing.

position values mirror insertAdjacentElement:

| Position | Where | | --- | --- | | beforeend (default) | Inside parent, after its last child. | | afterbegin | Inside parent, before its first child. | | beforebegin | As parent's previous sibling. | | afterend | As parent's next sibling. |

For beforebegin and afterend, the first argument is a sibling reference, not a true parent.

The Discipline

on_the_money has no reactivity primitives. No signal, effect, autorun, watch, atom, store, derived state, or subscription. There is also no event broadcast on the() writes. If you find yourself reaching for any of these — including importing them from another library — you're solving a problem the framework rejects.

Instead, the framework has three state-response mechanisms, each with one job:

1. CSS attribute selectors handle state → visual

Default. Anything visual goes through [data-key="value"] selectors in your stylesheet.

body[data-modal="session-expired"] #session-expired-modal { display: block }
body[data-page="home"] main > section[data-route="home"] { display: block }
[data-state="loading"] .spinner { display: inline }
the("modal", "session-expired");   // CSS rule above runs the visual change

No JS step between the the() write and the visual update. The CSS rule is the correspondence. This is the framework's reason for existing — DOM-as-state-database is valuable specifically because CSS can read that database without JS.

2. Imperative API calls colocated with state writes

For one-off platform side effects (dialog.showModal(), el.focus(), el.scrollIntoView(), media .play(), animation triggers), the imperative call lives in the same on() handler that wrote the state.

on("button.open", "click", () => {
  the("modal", "session-expired");
  $("#session-expired-modal").showModal();
});

Cause (user click) and imperative effect (showModal()) are colocated. The the() write records the state; the platform call performs the imperative action. No subscriber between them.

3. MutationObserver for DRY of imperative responses across many call sites

When the same state can be written from multiple places (user click, fetch handler, boot replay, postMessage) and they all need the same imperative response, observe the attribute once:

new MutationObserver(() => {
  const want = the("modal") ?? "";
  for (const open of $$("dialog[open]")) {
    if (open.id !== `${want}-modal`) open.close();
  }
  if (want) $(`#${want}-modal`)?.showModal();
}).observe(document.body, { attributes: true, attributeFilter: ["data-modal"] });

The observer doesn't enable reactivity — it deduplicates the imperative response. Any place in the codebase that writes data-modal gets the same dispatch logic without copy-pasting.

When MutationObserver is right vs wrong

| Right (the platform call does work CSS can't reach) | Wrong (CSS's job) | | --- | --- | | el.focus(), el.blur() — moving keyboard focus | Showing/hiding via display: none | | el.scrollIntoView(), window.scrollTo(...) — viewport movement | Changing color, layout, transform | | media.play(), media.pause() — playback state | Adding/removing a class | | el.animate(...) — Web Animations timing | Conditional opacity / visibility | | el.requestFullscreen() — browser mode | Animation triggers expressible as CSS transitions | | navigator.clipboard.writeText(...) — out-of-DOM side effects | Toggling text content (use [data-text]) |

dialog.showModal() is a borderline case: it shows the dialog (visual) AND traps focus, paints the backdrop, registers an Escape handler, and announces modal semantics to assistive tech (behavioral + a11y). If you want only the visual, dialog[open] + CSS suffices. If you want the bundled behavior, call showModal() — and reach for MutationObserver only if data-modal can be set from multiple call sites that all need the same dispatch.

If your MutationObserver callback is setting styles, toggling classes, or changing textContent, you've smuggled a JS reactivity layer into a project that explicitly rejected one. The CSS attribute selector was already doing that job — declaratively, with zero JS, with the right perf shape.

The deletion test

The single best self-check: strip every JS function except on() handlers, the imperative calls inside them, and any MutationObserver that handles a many-sites imperative response. Open the page; mutate body data-* attributes in DevTools.

  • Does the page respond correctly? You're idiomatic.
  • Does it not? You're missing either a CSS rule (most likely) or an imperative call at a state-write site (occasionally).

Element-as-state-carrier

Many platform elements already carry state in their own attributes, with native CSS hooks. When the state genuinely lives on a specific element, use that — don't add a body[data-x] indirection:

| Element | Native state attribute | What you do | | --- | --- | --- | | <dialog> | [open] | el.setAttribute("open", "") + Pico's dialog[open] CSS, or el.showModal() if you need the backdrop and focus trap | | <details> | [open] | Same shape; browser handles the disclosure UX | | <input>, <textarea> | [aria-invalid], [aria-required], [disabled] | Direct setAttribute; CSS targets :invalid, [disabled] | | <button> | [aria-pressed] (toggle state), [disabled] | Native focus/style for free | | <select> | The current value | Read via el.value; no body indirection needed |

The body[data-modal] indirection is correct when the state is a named enum the consumer controls (which modal is open, current page, theme). When the state genuinely lives on an element (is THIS dialog open?), let it live there.

Banned vocabulary, in spirit

If you're searching online for "how to subscribe to OTM state changes" or "OTM reactive system" — you're looking for something the framework doesn't have and won't add. The three mechanisms above are the whole story. Reactivity-by-CSS, imperative-by-handler, DRY-by-observer.

Patterns

Form intake

on("#todo-form", "submit", (e) => {
  e.preventDefault();
  const data = the.form(e.target);                          // { task, tags: [...] }
  the($.clone("#todo-list", "#todo-item"), the.flat(data)); // task, tags_0, tags_1 → attrs
});

List rendering with i18n

<template id="post-card">
  <article data-item>
    <h2 data-text="title"></h2>
    <p data-i18n="posted_at" data-i18n-val=""></p>
  </article>
</template>
<section id="posts"></section>
for (const post of posts) {
  const el = $.clone("#posts", "#post-card");
  the(el, { title: post.title });
  $(el, '[data-i18n="posted_at"]').setAttribute("data-i18n-val", post.created_at);
}
_t();

Routing (multi-page SPA)

route((path) => {
  the("page", path === "/" ? "home" : path.slice(1));
});
main > section { display: none; }
[data-page="home"]    main > section[data-route="home"]    { display: block; }
[data-page="about"]   main > section[data-route="about"]   { display: block; }
[data-page="contact"] main > section[data-route="contact"] { display: block; }

Hot dictionary swap

on("nav", "click", "[data-lang]", async (_e, link) => {
  const lang = link.getAttribute("data-lang");
  the.locale = lang;
  the.dictionary = await (await fetch(`/locales/${lang}.json`)).json();
  _t();
});

Namespaced state

// Two apps on the same origin can coexist:
await the.boot({ namespace: "dashboard" }); // localStorage keys: dashboard:theme, etc.

Cleanup with on() unsubscribe

const off = on("#modal", "click", "[data-action='close']", closeModal);
on.emit(document.body, "modal-closed");
off(); // detach when modal is destroyed

Routing with the.match

route(() => {
  const post = the.match("/posts/:slug");
  if (post) return renderPost(post.slug);

  const profile = the.match("/@:user");
  if (profile) return renderProfile(profile.user);

  if (location.pathname === "/") return renderHome();
});

Imperative dispatch from many sites (mechanism #3)

This is the case-3 pattern from The Discipline: the same state can be written from several places (user click, fetch error handler, boot replay, etc.) and all of them need the same imperative response. Observe the attribute once, dispatch from there:

new MutationObserver(() => {
  const wantModal = the("modal");
  for (const dialog of $$("dialog[open]")) {
    if (dialog.id !== `${wantModal}-modal`) dialog.close();
  }
  if (wantModal) $(`#${wantModal}-modal`)?.showModal();
}).observe(document.body, { attributes: true, attributeFilter: ["data-modal"] });

document.body is the allowed identifier under otm/no-document-query. Filter by attributeFilter to scope the observer narrowly.

Before reaching for this pattern, check whether CSS can do the job. If the response is purely visual (show/hide, color, layout), the CSS attribute selector is the right tool and the observer is overhead. Reserve MutationObserver for imperative APIs CSS can't express (showModal, focus, scrollIntoView, media controls, animation triggers).

Working around _t() with an empty dictionary

When the.boot({ defaultLocale }) skips the dictionary fetch, programmatic _t(key, options) calls return the key (no template to interpolate against). For default-locale interpolation, use template literals or a small wrapper:

// Template literal — the platform's interpolation primitive
const greeting = `Hello, ${name}!`;

// Or wrap once for callers that need a uniform API across locales
function greet(name) {
  return the.dictionary.greeting
    ? _t("greeting", { name })
    : `Hello, ${name}!`;
}

Server-side rendering (no extra API)

There's no SSR shim. Emit data-* attributes from the server:

<!-- rendered server-side -->
<body data-theme="dark" data-user="Alice">
  <h1 data-text="user">Alice</h1>
</body>

On the client:

await the.boot();   // localStorage values, if any, override the server-rendered attrs

The framework reads existing attributes via the(key) without modification, so server-rendered state is observable immediately. the.boot() only mutates when localStorage has matching keys.

Lint stack

on_the_money ships a three-tool stack. Each layer covers what the others can't.

1. JavaScript — ESLint + bundled plugin

The ESLint plugin and config ship inside on_the_money itself. No separate eslint-plugin-otm package — import via subpath.

npm install -D eslint eslint-plugin-no-unsanitized
// eslint.config.js
import otm from "on_the_money/eslint-config";
import nounsanitized from "eslint-plugin-no-unsanitized";

export default [
  ...otm,
  nounsanitized.configs.recommended,
];

| Rule | Source | Behavior | | --- | --- | --- | | otm/prefer-on | bundled in on_the_money | Ban addEventListener; use on(). | | otm/prefer-the-set | bundled in on_the_money | Ban textContent/innerText/nodeValue assignment. | | otm/flat-state | bundled in on_the_money | Ban nested objects/arrays in the() calls. | | otm/prefer-submit | bundled in on_the_money | Warn on on(btn, "click", ...) for form data. | | otm/no-style-mutation | bundled in on_the_money | Ban el.style.* = .... | | otm/no-server-dom | bundled in on_the_money | Ban imports of linkedom, jsdom, cheerio, parse5, etc. — emit JSON and let the OTM client hydrate. | | otm/no-document-query | bundled in on_the_money | Ban document.querySelector/getElementById/createElement/etc.; use $/$$/$.clone. Use // eslint-disable-next-line otm/no-document-query for legitimate bridge code (MutationObserver targets, etc.). | | no-unsanitized/no-inner-html | eslint-plugin-no-unsanitized | Ban innerHTML/outerHTML. | | no-unsanitized/method | eslint-plugin-no-unsanitized | Ban document.write, insertAdjacentHTML. |

The recommended config matches **/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx} — your TypeScript files are covered if you bring a TS-aware parser like @typescript-eslint/parser. OTM's rules read AST nodes that don't depend on type info, but the parser still has to understand the syntax.

2. CSS — Stylelint + bundled plugin

Same pattern: the Stylelint plugin and config ship inside on_the_money. No separate stylelint-plugin-otm package.

npm install -D stylelint stylelint-config-standard
// stylelint.config.js
import otm from "on_the_money/stylelint-config";

export default {
  extends: ["stylelint-config-standard"],
  ...otm,
};

| Rule | Source | Behavior | | --- | --- | --- | | otm/prefer-attribute-selector | bundled in on_the_money | Ban .class selectors; use [data-state="..."]. | | declaration-no-important | stylelint built-in | Ban !important. |

3. HTML / cross-file — otm-lint

npx otm-lint --check ./src

| Rule | Forbidden | Use instead | | --- | --- | --- | | HTML-004 | Naked text in HTML | Wrap in a semantic tag or use data-i18n="key". | | HTML-017 | <div data-action="..."> without role/tabindex | Use a <button> or other interactive element. | | HTML-023 | data-i18n="..." without <meta name="i18n"> | Declare the i18n endpoint. | | HTML-024 | data-available="..." doesn't match locales folder | Keep the manifest aligned with the actual locale files. | | HTML-101 | <template id="X"> is never referenced by $.clone(_, "#X") | Either delete the orphan template or add the missing clone call. Catches dead-template drift after refactors. Detection is regex-based and matches the literal $.clone(_, "#id") shape — dynamic IDs (`#${id}`), aliased calls, or templates instantiated through indirection are missed. Use data-otm-dynamic on the <template> to opt out. | | HTML-102 | data-i18n="K" references a key not in any locale dictionary | Add the key to your locale files, or fix the typo. Catches the silent-fallback-to-key UX bug at lint time. | | HTML-103 | data-i18n-{var} attr has no matching {var} placeholder in the dictionary template | The token is silently dropped at runtime. Either remove the unused attr or add {var} to the template. |

otm-lint walks .html, .js, and locale .json files. HTML files get the per-file rules above; .js files contribute $.clone references for HTML-101; locale dicts get loaded per HTML file's <meta name="i18n"> for HTML-102/103. Default excludes: node_modules, dist, .git, dotdirs. Exit code is the violation count.

Lint-rule scope

These rules are discipline aids, not enforcement boundaries. Each catches the obvious shape; determined consumers can route around them:

  • otm/no-server-dom matches static import only. Dynamic await import("linkedom") and CJS require() are not caught.
  • otm/no-document-query catches document.querySelector(...) and friends at AST level. Chained calls (document.body.querySelector(...), document.getElementById("x").querySelector(...)) are not caught — the call site has moved off document.
  • HTML-101 regex-matches $.clone(parent, "#id") in .js files. Dynamic IDs and aliased calls are missed; use data-otm-dynamic to opt out of the check for templates instantiated indirectly.

The rules' job is to catch the first mistake and surface a teaching message ("Common LLM mistake: ..."). Strong patterns + good editor feedback do the rest.

Recommended companions (not shipped)

| Concern | Tool | | --- | --- | | HTML correctness (lang/charset/viewport, inline handlers, deprecated attrs) | html-validate | | Static a11y | eslint-plugin-jsx-a11y (works on plain HTML via parsers) | | Runtime a11y | axe-core via Playwright/Cypress against the rendered page | | Supply-chain | npm audit, osv-scanner |

Production CSS purging

OTM's CSS depends on attribute selectors written at runtime (body[data-page="home"], [data-state="active"], etc.). Default tree-shakers like PurgeCSS scan HTML for tokens and can't see attribute values that don't exist at build time — they'll strip your state-driven rules. Configure the safelist to preserve them:

// postcss.config.js
import purgecss from "@fullhuman/postcss-purgecss";

export default {
  plugins: [
    purgecss({
      content: ["src/**/*.html", "src/**/*.js"],
      defaultExtractor: (s) => s.match(/[A-Za-z0-9_-]+/g) || [],
      safelist: {
        standard: [/^aria-/, "hidden"],
        deep: [/^\[data-/, /^aria-/, /role-/],
      },
    }),
  ],
};

The defaultExtractor tweak ensures attribute names tokenize correctly (the default extractor treats id="foo" as one opaque token). The deep safelist preserves any selector containing [data-*] or aria-* from being purged. Without these, every [data-page="home"] main > section style gets stripped in production.

Suggested project layout

my-app/
├── index.html              # Pico + meta tags + script entry
├── app.js                  # await the.boot(); register handlers
├── locales/
│   ├── en.json
│   ├── es.json
│   └── fr.json
├── styles.css              # attribute-selector-driven custom CSS (optional)
└── views/
    ├── home.html           # fragment imported via fetch + $.clone, or inline <template>
    └── about.html

<meta name="i18n" content="/locales" data-available="en,es,fr" data-fallback="en"> directs the.boot() to fetch /locales/{lang}.json automatically.

Repository layout (contributors)

src/
├── core/
│   ├── index.js           # public exports
│   ├── On.js              # event delegation + emit
│   ├── The.js             # state, i18n, boot, route, form, flat
│   └── Select.js          # $, $$, clone
└── linter/
    ├── main.js            # otm-lint binary entrypoint
    ├── cli.js             # directory scan, output
    └── Linter.js          # rule implementations
test/
└── integration.test.js    # cross-module integration suite
dist/
└── on_the_money.min.js    # built ESM bundle (esbuild)

Unit tests are co-located with source: src/core/On.test.js, etc.

Commands

| Script | Purpose | | --- | --- | | npm test | Run all tests (node --test). | | npm run test:coverage | Tests + coverage report (50/50/50 line/branch/function gate). | | npm run lint | Biome formatter + import order. | | npm run build | Build dist/on_the_money.min.js via esbuild. | | npm run check | lint + build + test + coverage. CI gate. | | npm run otm | Self-lint examples/ against project rules. |

License

MIT. Source and issues: https://github.com/possumtech/on_the_money.js