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

@gregor_herdmann/web-core

v2.6.3

Published

Shared toolchain and runtime dependencies for the web-* apps

Readme

@gregor_herdmann/web-core

Shared toolchain and runtime dependencies for the web-* apps.

The point is dependency consolidation, not config sharing. The five apps used to each carry the same pins, so every Dependabot update was reviewed and merged five times. Two groups now live here instead:

  • 21 tooling packages, as regular dependencies. Nothing in an app imports these; they are reached through the web-core CLI, so transitive is enough.
  • 13 runtime packages, as required peerDependencies — react, react-dom, react-router-dom, lucide-react, date-fns, express, express-session, cors, memorystore, googleapis, i18next, react-i18next and recharts.

The distinction matters. App source really does import 'react', so react has to be resolvable from the app's own node_modules, and there has to be exactly one copy of it. dependencies only land there by npm's best-effort hoisting, which is not a guarantee — web-todo's lockfile nests @vitejs/plugin-react under this package rather than hoisting it. npm installs required peers at the consumer root by construction, so that is what the runtime group uses.

Using it in an app

// package.json
{
  "scripts": {
    "build": "web-core build",
    "typecheck": "web-core typecheck",
    "lint": "web-core lint",
    "format": "web-core format",
  },
  "prettier": "@gregor_herdmann/web-core/prettier",
  // A dependency, not a devDependency: peers of a devDependency are dropped by
  // `npm ci --omit=dev`, which would take express down with them at runtime.
  "dependencies": { "@gregor_herdmann/web-core": "2.0.0" },
}

An app declares none of the 13 runtime packages and none of the tooling. Its own dependencies are only what is genuinely its own — pdfkit, @dnd-kit/*, puppeteer-core and the like.

To deviate from a pin, use overrides. A plain direct dependency at a different version is an ERESOLVE failure against an exact peer, which is the point:

{ "overrides": { "date-fns": "4.5.0" } }
// eslint.config.js
export { default } from '@gregor_herdmann/web-core/eslint';
// tsconfig.json — paths and include stay HERE, see below
{
  "extends": "@gregor_herdmann/web-core/tsconfig.base.json",
  "compilerOptions": { "paths": { "@/*": ["./src/*"] } },
  "include": ["src"],
}
// vite.config.ts
import { defineAppConfig } from '@gregor_herdmann/web-core/vite';

export default defineAppConfig({
  chunks: {
    'vendor-react': ['react', 'react-dom', 'react-router', 'react-router-dom'],
  },
});

Delete the app's prettier.config.ts and .npmrc, and remove every tooling devDependency. vite in particular must go — two copies of vite means two plugin instances and confusing failures.

Testing

web-core test runs vitest. Apps declare no test dependencies — vitest, jsdom, Testing Library and coverage all come from here, and defineAppConfig wires up the environment and jest-dom matchers, so a test file is all an app needs to add.

// src/lib/money.test.ts — pure unit test
// @vitest-environment node
import { expect, it } from 'vitest';

// src/components/Thing.test.tsx — component test, jsdom is the default
import { render, screen } from '@testing-library/react';

web-core test:watch and web-core test:coverage are also available. Every app carries a test script, and --passWithNoTests keeps that honest before the first test exists.

Shared UI

@gregor_herdmann/web-core/ui holds the components every app draws the same way.

| Export | What it is | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | AppShell | The whole layout: collapsible sidebar (brand, nav, settings, theme toggle, collapse), <main>, and the dark-mode state. top/footer slots take app-specific sidebar content; storageKey remembers the collapse. Must sit inside the router | | PasswordGate, GoogleConnectGate, EnvCheckGate | The login and setup screens, against the existing /api/auth/me, /api/auth/login, /api/auth/status and /api/env-check. PasswordGate children may be a function receiving the me body, for apps with roles | | ConfirmProvider, useConfirm | if (!(await confirm({ title, danger: true }))) return; instead of window.confirm. Focus starts on Cancel, Escape cancels | | useDarkMode, useIsDark, DarkModeContext | Follows the system scheme; the toggle overrides it for the session. AppShell already calls useDarkMode, so pages only need useIsDark | | AppSwitcher, APPS | Sidebar brand with a menu to the other apps; the app registry | | Button, IconButton | variant primary/secondary/ghost/danger, size sm/md, icon, pending (spinner, width kept). type defaults to "button". IconButton requires label, takes tone="danger" for delete, size="sm" for dense rows and the same pending. An in-flight button uses pending, never a label that changes to "Saving…" | | Field, Input, Select, Textarea, inputClass | Label wrapping its control, and the input look | | Dropdown, MultiDropdown | Styled single and checkbox dropdowns with keyboard support and a list that escapes overflow. Optional icon per option, clearLabel for a ×; MultiDropdown shows a summary(count) or a count badge. Prefer the native Select for plain form fields | | Card, StatCard, PageHeader, SectionTitle, EmptyState, Badge | Page layout pieces | | Modal | Dialog with title, closeLabel, optional footer and size. Escape and backdrop close it, Tab stays inside, focus returns to the opener | | SortableTh, useSort, compareValues | Sortable column header (aria-sort); sort state with optional storageKey and firstDir; a comparator with German collation and blanks last | | ThemeToggle | Sidebar light/dark switch with lightLabel/darkLabel | | formatEUR, formatNumber, formatDateDE | Cached de-DE formatters |

Rules for what goes in here:

  • Text comes in through props. The apps are partly German, partly English, and budget uses i18next, so no component hardcodes a word. Icon-only controls require a label.
  • Presentational only. Domain dialogs, charts and badges stay in their app.
  • className is appended, not merged. Pass layout (w-full, margins), not colours.
  • Only slate-*, emerald/amber/rose, accent-* and dark: classes, so each app's own palette and dark mode apply.
// src/components/Sidebar.tsx
import { AppSwitcher } from '@gregor_herdmann/web-core/ui';

<AppSwitcher current="budget" icon={<WalletIcon />} label={t('app.title')} collapsed={collapsed} />;
/* src/index.css — directly below @import 'tailwindcss' */
@import '@gregor_herdmann/web-core/ui.css';

Without that CSS import the components render unstyled. Tailwind never scans node_modules, and ui.css is an @source pointing at the compiled components. It also defines the ease-sidebar utility AppShell animates with, and restores cursor: pointer on every enabled button, [role=button], select, summary and checkbox label, which Tailwind v4's preflight resets to the default arrow. So a plain <button> needs no cursor-pointer class.

The app URLs live in ui/apps.ts, and nowhere else. To add or move an app, edit that file and release. The fan-out then carries the change to every app. Components are written in TSX and compiled into dist/ui by npm run build, which prepack runs, so the published package and the test fixture always get fresh output.

Things that will bite you

  • paths, include, outDir and rootDir must stay in each app's tsconfig. TypeScript resolves relative paths against the file that declares them, so a shared outDir: "dist" would emit the server build into node_modules/ and production would fail on a missing dist/server/index.js.
  • The CLI is insurance, not magic. npm hoists transitive bins, so vite and tsc would land in an app's .bin anyway. The wrapper makes resolution deterministic (and survives pnpm), which is also why ejecting is cheap.
  • Consumers must not add vite back as a direct dependency.
  • Every runtime package is declared twice here, at the same pin: once in peerDependencies (what consumers resolve against) and once in devDependencies (what Dependabot bumps, since it does not open PRs for peer-only entries). The suite fails if the two drift; npm run sync-peers is the fix. A peer with no matching devDependency never gets bumped at all — which is what happened to @testing-library/react.
  • Nothing in peerDependenciesMeta may be optional. Optional peers are not auto-installed, so an app would end up with no react at all.

Releasing

npm version minor && git push --follow-tags

The tag triggers publish to npm with provenance, then a repository_dispatch fan-out that opens a bump PR in all seven apps. Releases are deliberately manual: Dependabot auto-merges patch and minor bumps into main here, but never publishes.

Required secrets: NPM_TOKEN (granular, read-write on @gregor_herdmann/*) and FANOUT_TOKEN (fine-grained PAT with Contents + Pull requests write on the seven app repos). The fan-out must use a PAT rather than GITHUB_TOKEN, because pushes made with GITHUB_TOKEN do not trigger the app's CI.

scripts/rotate-fanout-token.sh distributes a newly minted FANOUT_TOKEN to this repo and to every app in the fan-out matrix, which it reads out of release.yml so the list cannot drift. --check reports where the secret is set without changing anything, and --dry-run prints what a real run would do. Granting the PAT its repository access stays manual — the REST API does not expose it.

Ejecting

Add the pins back to the app — the tooling ones are listed in this package's dependencies, the runtime ones in its peerDependencies — restore the config files from the app's pre-web-core tag, and revert the scripts. Around 15 mechanical minutes per repo, and the app keeps working mid-eject: re-declaring a package at the version it is already resolving to changes nothing about the tree.

Tests

npm test packs this package, installs the tarball into test/fixtures/app, and runs the real toolchain against it. The negative cases matter most — they distinguish "the check passed" from "the check ran on zero files", which is how a bad include or a misrouted ESLint config would otherwise slip through as a green build.