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

@moku-labs/web

v2.3.1

Published

Content static-site generator + SPA web framework for TypeScript, built on @moku-labs/core.

Readme

@moku-labs/web

Content static-site generator + progressive SPA framework for TypeScript.

Author Markdown, declare type-safe routes, ship SEO-complete static HTML — then hydrate islands and navigate on the client. The same render runs at build and in the browser, so SSG ⇄ SPA parity is structural, not duplicated.

Built on the @moku-labs/core micro-kernel — three layers of isolation, plugins all the way down, types doing the heavy lifting.

CI npm types browser bundle node license: MIT

Quick start · How it works · The route is the contract · Plugins · Rendering modes · Scripts · Docs


bun add @moku-labs/web preact preact-render-to-string

[!NOTE] preact (and preact-render-to-string, used by the SSG build) are peer dependencies — your app compiles its JSX against the same single preact instance the framework renders with. Most package managers install peers automatically, but declare them explicitly so you own the version: a second nested copy of preact silently breaks hooks and island hydration.

[!NOTE] Status: 1.x — stable. The architecture and public API are stable and follow semver — breaking changes land only in a new major. The npm badge above tracks the current release.

Why @moku-labs/web

  • SSG first, SPA when you want it. Render Preact pages to static HTML for SEO and instant first paint, then progressively enhance with island hydration and client-side navigation — opt in per project with a single switch.
  • The route is the contract. One typed route() builder owns loadrenderhead. The build and the client run the same render, so there's no second code path to keep in sync. Jump to the example ↓
  • SEO complete out of the box. Title templates, canonical + hreflang, Open Graph / Twitter cards, JSON-LD, RSS / Atom / JSON feeds, sitemap.xml, and generated OG images.
  • The /browser entry is guaranteed node-free. A dedicated client entry whose static import graph references zero node modules — native code can never leak into your bundle, no matter your bundler or tree-shaking. A CI gate keeps it under budget (~50 kB gzip today, 60 kB budget). Why this matters ↓
  • Plugins all the way down. A tiny isomorphic core (site, i18n, router, head, spa) plus opt-in node-only plugins (content, build, deploy, cli), each independently documented and composed in one createApp call.
  • Types do the heavy lifting. ctx.data is inferred from your .load(), path params from the route pattern, plugin APIs from their specs — no codegen, no as.
  • i18n is built in. Locale-aware routes, default-locale fallback, hreflang / og:locale maps.

Quick start

A complete static blog is two files: the routes, and the app that builds them.

// routes.tsx — the route IS the contract: load → render → head
import { route, contentPlugin } from "@moku-labs/web";
import { Article } from "./components";

export const home = route("/")
  .render(() => <h1>My Blog</h1>)
  .head(() => ({ title: "My Blog" }));

export const post = route("/{slug}/")
  // generate the page list at build time…
  .generate((ctx) => listSlugs(ctx.locale).map((slug) => ({ slug })))
  // …load() runs at build only; pull sibling plugins via ctx.require → widens ctx.data
  .load((ctx) => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale))
  // render() runs at build AND on the client; ctx.url builds type-safe links
  .render((ctx) => <Article article={ctx.data} url={ctx.url} />)
  .head((ctx) => ({ title: ctx.data.title, description: ctx.data.description }));
// app.ts — compose the build, then run it
import { createApp, contentPlugin, buildPlugin, fileSystemContent, processEnv } from "@moku-labs/web";
import * as routes from "./routes";

const app = createApp({
  plugins: [contentPlugin, buildPlugin],   // node-only plugins — opt in for the build
  config: { mode: "ssg" },                 // "ssg" | "spa" | "hybrid"  (see Rendering modes)
  pluginConfigs: {
    site:    { name: "My Blog", url: "https://blog.dev", author: "Me", description: "A personal blog." },
    i18n:    { locales: ["en"], defaultLocale: "en" },
    content: { providers: [fileSystemContent({ contentDir: "./content" })] },
    router:  { routes },                   // a declarative route map (an `import * as` namespace works)
    env:     { providers: [processEnv()] },
  },
});

await app.build.run();   // → dist/ : static HTML + feed.xml + sitemap.xml

Content lives on disk as content/{slug}/{locale}.md with YAML frontmatter. Drafts are excluded when config.stage is production (the default); they surface in development and test.

content/
  hello-world/
    en.md      # frontmatter: title, date, description, tags, language, draft?, author?
    uk.md      # same slug, another locale — i18n fallback handles the rest
  second-post/
    en.md

Add client-side navigation

Import from the /browser entry. It's the same createApp over the same isomorphic defaults, with all node-only code excluded and env pre-wired — so env needs zero config.

// client.ts — guaranteed node-free; env reads import.meta.env out of the box
import { createApp, dataPlugin } from "@moku-labs/web/browser";
import * as routes from "./routes";   // render shells only — never the node content source

const app = createApp({
  plugins: [dataPlugin],              // opt in for DATA-driven navigation (mode spa | hybrid)
  config: { mode: "spa" },
  pluginConfigs: { router: { routes } },
});

await app.start();   // spa mounts islands onto the SSR'd DOM and intercepts navigation

Per-route transitions, scroll, and programmatic nav

The behaviour of a navigation is declared on the route (a typed framework directive, not free-form .meta()), with an app-wide default on the spa plugin. Both .transition() and .scroll() override the default for navigations to that route:

spa: { islands, viewTransitions: "crossfade", scrollRestoration: "top" } // app defaults

route("/board/{id}").transition("slide")                 // board↔board slides
route("/board/{id}/issue/{issueId}")
  .transition("morph")                                   // the card morphs into the panel
  .scroll("preserve")                                    // overlay: don't move the board behind it
  • viewTransitions accepts boolean (true"crossfade", false"none") or a named TransitionMode ("none" | "crossfade" | "slide" | "morph"). A named transition tags the swap so your CSS can target it two ways — :root[data-view-transition~="slide"] (every View-Transitions engine) and the standards-track :active-view-transition-type(slide) — and a shared view-transition-name left across the swap morphs one element into another. Reduced-motion and unsupported browsers fall back to an instant swap.
  • scrollRestoration is "top" (reset, the default) or "preserve" (keep the current scroll — e.g. opening an overlay route over content that must stay still). Back/forward always restores its saved position.
  • ctx.navigate(path, options?) — every island can navigate programmatically with no app handle (an always-present context member, like ctx.set/ctx.url). app.spa.navigate takes the same optional { scroll }.
  • navigate(path, options?) / hardNavigate(url) — importable from @moku-labs/web/browser for module-scope callers (shared chrome/nav helpers with no app or island ctx); they bind to the single booted app and no-op before app.start(). hardNavigate (also app.spa.hardNavigate) does a REAL full-page load for crossing a boundary the SPA can't swap — a different layout or an auth split — by detaching the SPA's own interceptor first, so location.assign isn't caught and converted to a region swap.
createIsland("issue", {
  // `null` renders nothing but stays mountable (Preact unmount, not innerHTML="") —
  // the correct "empty" for a persistent overlay panel, so it re-opens cleanly:
  render: (s) => (s.open ? h(IssuePanel, { issue: s.issue }) : null),
  events: { "click [data-close]": (ctx) => ctx.navigate(ctx.url("board", { id: ctx.params.id })) },
});

Live realtime channels

createChannel is a self-healing client WebSocket primitive for islands that consume a server push stream (e.g. a Durable Object). It owns reconnect-with-backoff, an optional keepalive, a pre-seed buffer that closes the connect→load race, refcounted subscribe (first in connects, last out disconnects), and an optimistic deliverLocal echo — browser-only and tree-shaken away unless used:

import { createChannel } from "@moku-labs/web/browser";

const board = createChannel<BoardPatch>({
  url: (id) => `${location.origin.replace(/^http/, "ws")}/ws/board/${id}`,
  keepAlive: { send: "ping", ignore: "pong" },
  bufferUntilSeed: true,
});
// island onMount:
const off = board.subscribe(boardId, (patch) => applyPatch(ctx, patch));
ctx.cleanup(off);
const snapshot = await getBoard(boardId);
board.seed(boardId);   // flush anything that arrived during the load

How it works

Three layers, one createApp

You only ever touch Layer 3: a single createApp call. Defaults are the isomorphic plugins that run unchanged on Node and in the browser. The node-only plugins are exported but not defaults — you add them for a build. You never import from @moku-labs/core directly.

flowchart TB
  A["Your app — createApp({ plugins, pluginConfigs })"]:::l3
  ISO["<b>Isomorphic defaults</b> — run on Node + browser<br/>site · i18n · router · head · spa<br/>+ log · env (core)"]:::l2
  NODE["<b>Opt-in</b> — composed per target<br/>content · build · deploy · cli (node-only)<br/>data (isomorphic, optional)"]:::l2
  CORE["@moku-labs/core micro-kernel<br/>4-level config cascade · events · lifecycle"]:::l1
  A --> ISO
  A -. "add for an SSG build" .-> NODE
  ISO --> CORE
  NODE --> CORE
  classDef l3 fill:#0b7285,stroke:#08525f,color:#fff;
  classDef l2 fill:#1864ab,stroke:#0d3d6e,color:#fff;
  classDef l1 fill:#343a40,stroke:#111,color:#fff;

The same render runs in both places

build calls each route's load() then render() to emit static HTML, and (in spa/hybrid mode) data.write() persists that page's load() output as a JSON sidecar. On a client navigation, data.at() fetches that JSON and spa re-runs the route's own render from it. One render function, two runtimes — parity is structural.

flowchart LR
  subgraph BUILD["① Build time · Node"]
    direction TB
    L1["load(ctx) → data"] --> R1["render(data) → static HTML"]
    L1 --> W1["data.write → JSON sidecar (one per page URL)"]
  end
  subgraph CLIENT["② Client · browser"]
    direction TB
    N2["in-app navigation"] --> A2["data.at(path) → JSON"] --> R2["render(data) → live DOM"]
  end
  R1 -. "same render() — no second code path" .-> R2

The /browser entry is guaranteed node-free

There are two entry points, and the difference is a hard guarantee, not a tree-shaking hope:

| Entry | Format | For | Includes | |---|---|---|---| | @moku-labs/web | dual ESM + CJS | Node SSG builds | the full surface — add content / build / deploy / cli and wire dotenv() / processEnv() | | @moku-labs/web/browser | ESM-only | client bundles | the same createApp over the same isomorphic defaults, plus dataPlugin, the route DSL, createIsland, browserEnv, and the SEO head primitives — with all node-only code excluded (build / deploy / cli and the dotenv / processEnv / fileSystemContent providers), and browserEnv() pre-wired as the default env provider |

Importing @moku-labs/web/browser can never drag node/native code into a client bundle, regardless of bundler or tree-shaking — its static import graph references zero node-only modules. This is stronger and more reliable than importing the main entry and relying on "sideEffects": false, where building entries together can merge node code into a shared chunk. (The browser entry keeps the contentPlugin shell so build-only loaders can ctx.require(contentPlugin); the node Markdown source lives in fileSystemContent, which the entry does not export.) CI proves it:

bun run check:bundle   # asserts: zero static node/native imports + under the gzip budget

The route is the contract

A route is a fluent builder. Each step is optional except render, and every type flows from it:

route("/{lang:?}/{slug}/")        // path-params are inferred → ctx.params.{lang, slug}
  .generate((ctx) => Params[])    // build-time: which concrete pages to emit
  .load((ctx) => Data)            // build-only: ctx.require / ctx.has pull sibling plugins; widens ctx.data
  .render((ctx) => VNode)         // build AND client: ctx.url builds links; ctx.data is typed from .load()
  .head((ctx) => HeadConfig)      // SEO <head>: ctx.url + ctx.data available
  • ctx.params — inferred from the pattern. {seg} is required, {seg:?} is optional (used here for an optional locale prefix).
  • ctx.require(plugin) / ctx.has(plugin) — pull a sibling plugin's API the spec way (instance-only, no module globals). Available in load / generate.
  • ctx.url(name, params) — type-safe link builder. Available in render / head.
  • ctx.data — typed from .load()'s return. On a client nav the fetched JSON is ctx.data directly (no validation step); a missing or malformed sidecar simply falls back to HTML-over-fetch. Omit .load() for a static page — build still emits an empty {} sidecar so hybrid nav resolves cleanly.

Register routes declaratively via pluginConfigs.router.routes — the single source of truth, compiled once at init.

SEO <head> primitives are exported for .head() handlers: meta, og, twitter, jsonLd, canonical, hreflang, feedLink, and buildArticleHead.

Plugins

Each plugin is small, single-purpose, and documented on its own. Click a name for its README — config, full API, and design notes.

| Plugin | Kind | Responsibility | |---|---|---| | site | isomorphic default | Global, frozen site identity (name, URL, author) + canonical URL helper | | i18n | isomorphic default | Locale registry, default-locale fallback, translations, og:locale map | | router | isomorphic default | Type-safe route() DSL, path-param inference, matcher, URL/file derivation, mode() | | head | isomorphic default | <head> composition: title template, canonical, OG/Twitter, JSON-LD, hreflang, feeds | | spa | isomorphic default | Client runtime: island hydration + intercepted navigation + progress bar (inert on Node) | | content | node-only | Markdown → sanitized HTML pipeline, frontmatter, reading time, locale-keyed Article model | | build | node-only | SSG orchestrator: pages, feeds (RSS/Atom/JSON), sitemap, OG images → dist/ | | deploy | node-only | Cloudflare Pages: wrangler.jsonc scaffolding, secret scrubbing, deploy | | cli | node-only | Developer CLI — build / serve / preview / deploy with the animated Velocity Panel UI (lockup + version banner, live phase tree, boxed panels, live pulse) | | data | optional provider | Agnostic page path → JSON contract: write() on Node, at() in the browser, for DATA nav | | env | core | Multi-provider environment / secret injection, validated and frozen at onInit | | log | core | Structured logging + an in-memory trace with an expect() DSL for testable workflows |

Rendering modes

One global switch — config.mode (default hybrid), read by plugins via router.mode() — decides how much of the SPA machinery runs.

| Mode | Build emits | Client behavior | Use for | |---|---|---|---| | ssg | HTML only | static pages (full reload on nav) | content sites, docs, marketing | | spa | HTML + JSON sidecars | DATA navigation: fetch JSON, re-render in place | app-like sites | | hybrid | HTML + JSON sidecars | DATA nav where available, else HTML-over-fetch | best of both |

[!TIP] Compose dataPlugin on both sides for DATA navigation — on Node so build can write sidecars, and on the browser entry so spa can read them. Omit it for a plain static site. It has no hard dependencies and tree-shakes away when unused.

Scripts

bun run build              # build with tsdown (dual ESM+CJS + ESM-only browser entry)
bun run test               # all tests (vitest)
bun run test:unit          # unit tests only
bun run test:integration   # integration tests only
bun run test:coverage      # tests with coverage (85% threshold)
bun run lint               # biome check + eslint
bun run lint:fix           # auto-fix lint issues
bun run format             # format with biome
bun run validate           # publint — verify package export map
bun run check:bundle       # assert the browser bundle is node-free + under the gzip budget

Requirements

  • Node >= 24 — the engines floor declared in package.json. (The route matcher is native RegExp — no URLPattern global needed, in Node or in any browser.)
  • Bun >= 1.3.14 — the package manager and test runner. Use bun exclusively (never npm/yarn/pnpm).
  • TypeScript in strict mode, with exactOptionalPropertyTypes and noUncheckedIndexedAccess.

Docs

License

MIT © moku-labs