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

@inneropen/marvin-astro

v1.1.0

Published

Site integration for Marvin CMS on Astro — repositories, site chrome, and payload normalization

Downloads

404

Readme

@inneropen/marvin-astro

Site integration for Marvin CMS on Astro: content repositories with static fallbacks, site chrome from collections, and payload normalization.

Three packages, three concerns:

| Package | Concern | |---|---| | @inneropen/marvin-sdk | transport — HTTP client, entries, collections, assets | | @inneropen/marvin-renderers-core | entry-type → Astro renderer component mapping | | @inneropen/marvin-astro | site integration — repositories, chrome, normalization |

A new site wires up to a Marvin workspace by installing one package, setting three env vars, and writing only its own transform functions.

npm install @inneropen/marvin-astro @inneropen/marvin-sdk
MARVIN_API_URL=https://marvin.example.com
MARVIN_SITE_CLIENT_TOKEN=site_client_…
MARVIN_WORKSPACE_SLUG=my-workspace

Quick start

// src/lib/content.ts
import { createMarvinContent } from '@inneropen/marvin-astro';
import { site as staticSite, mainNav, footerNav } from '../data/site';
import { posts as staticPosts } from '../data/posts';

export const marvin = createMarvinContent({
  site: { fallback: staticSite },
  chrome: { fallback: { mainNavigation: mainNav, footerNavigation: footerNav } },
});

const CATEGORIES = ['Making', 'Materials', 'Lessons'] as const;

export const posts = marvin.repository({
  collections: ['bench-notes', 'journal', 'blog'],   // tried in order
  hydrate: true,                                      // list items lack data_json — see below
  href: (slug) => `/bench-notes/${slug}`,
  fallback: () => staticPosts,
  sort: (a, b) => Date.parse(b.date) - Date.parse(a.date),
  transform: async (entry, f) => ({
    slug: entry.slug,
    title: entry.title ?? 'Untitled',
    date: f.string('date') ?? f.publishedAt() ?? '',
    noteNumber: f.string('noteNumber'),               // data_json → metadata_json
    category: f.oneOf('category', CATEGORIES, 'Making'),
    order: f.number('order') ?? 0,
    featured: f.bool('featured'),
    bodyHtml: await f.markdown('body'),
    image: f.image({ roles: ['hero', 'featured', 'card'] }),
    icon: f.icon(),
    href: f.href,
  }),
});
---
// src/pages/bench-notes/index.astro
import { posts, marvin } from '../../lib/content';

const all = await posts.all();
const { site, mainNavigation } = await marvin.getSiteChrome();
---

posts.all() · posts.bySlug(slug) · posts.featured() · posts.allFeatured() · posts.reset()

Why hydrate

The collection endpoint returns PublishedEntryListItem, which carries core fields and metadata_json but not data_json. The single-entry endpoint returns PublishedEntryRead, which includes it.

So if a transform reads any schema-defined field — anything beyond title/slug/summary/metadata — hydrate: true is required or those fields come back undefined. It costs one request per entry.

Field precedence

Every reader on f resolves data_json first and metadata_json second. An empty string counts as absent: an entry type that declares a field the author left blank stores "", and without the fall-through a legacy value that is set would never surface.

| | | |---|---| | f.string(key) f.number(key) f.bool(key) f.list(key) | scalars; bool reads "true"/"1"/"yes" | | f.oneOf(key, allowed, fallback) | enum guard — replaces per-field normalizeStatus-style helpers | | f.raw(key) f.data() f.metadata() | untyped escape hatches | | f.markdown(key?, { softBreaks }) | renders to HTML; undefined when there is nothing to render | | f.date(key) f.publishedAt() | display date ("Mon DD, YYYY") / raw ISO stamp | | f.image(options) f.images(options) f.icon(options) | resolved {src, alt, focalPoint} | | f.asset(options) f.assetByRole(...roles) f.assets() | raw asset placements | | f.resource(options) f.resources(options) | attached resources → {name, type, role, href} | | f.collections() f.role(collection) f.href | membership and routing context |

f.image() checks, in order: a hand-authored metadata_json.featuredImage, the exact preferRoles (for derived variants like a colour-graded hero), a role/usage match over the entry's image assets, then the list item's featuredAsset.

Role matching: selectEntryAsset ORs role against usage, and an absent usage criterion is vacuously true — so a role-only query matches the entry's first asset. When you mean "the asset whose role is exactly this", use f.assetByRole() / selectAssetByRole().

Site and chrome

const site = await marvin.getSite();       // identity, SEO, brand assets — memoized
const chrome = await marvin.getSiteChrome(); // nav, footer, legal, social, inquiry — memoized

getSite() resolves every site_metadata_json.brand.<name> asset slug to a URL in one pass, so a site adds a shared brand asset with one config line and reads site.brand.<name> — no code change per asset. logo, favicon and seal are aliased onto the top level.

getSiteChrome() reads main-navigation and footer-navigation collections, splits role: 'legal' entries into legalLinks, and groups the rest into footer columns. A nav entry's route comes from an explicit href/url/path field if it has one, otherwise from resolveHref:

resolveHref?: (entry, context) => string

The default prefixes the entry's own non-navigation collection: an entry in workshop-reference becomes /workshop-reference/<slug>, an entry in no other collection becomes /<slug>. Override when routes don't mirror collections.

The failure latch

A static build asks for content once per path. When the backend is down that means N failed requests with N timeouts. The latch trips on the first network failure — not a 404, which says nothing about the next entry — and short-circuits the rest.

It expires after retryAfterMs, so a dev server recovers on its own when the backend comes back instead of serving stale static data until someone restarts it. Defaults: 30s in dev, Infinity in production, since a build should fail fast and consistently rather than half-succeed with some pages live and some static.

createMarvinContent({ retryAfterMs: 5_000 });
marvin.backend.isLatched();
marvin.backend.clearLatch();

SEO head (optional)

One component ships, behind its own export path, so the core package stays pure TypeScript and Astro stays an optional peer dependency.

---
import { SeoHead } from '@inneropen/marvin-astro/astro';
import { marvin } from '../lib/content';

const { seo } = await marvin.getSite();
---
<head>
  <SeoHead {seo} pageTitle="Bench Notes" pageType="article" />
</head>

It emits title, description, robots, canonical, Open Graph, Twitter and search-engine verification tags. No styling, no site coupling.

Exports

| Path | Contents | |---|---| | @inneropen/marvin-astro | createMarvinContent and every helper below it | | @inneropen/marvin-astro/types | resolved types only (ApiSite, ApiSeo, ApiSiteChrome, …) | | @inneropen/marvin-astro/astro | SeoHead |

Beyond createMarvinContent, the pieces are usable on their own: createBackend, createFetcher, createRepository, createSiteLoader, createChromeLoader, createFieldAccessor, createMarkdownRenderer, formatDisplayDate, selectValuesForPage, and the whole normalize surface.

Development

npm install
npm run typecheck
npm test          # vitest, fixture-driven, no network
npm run build     # tsup → dist/ with .d.ts

Fixtures under tests/fixtures/ are captured from a live workspace rather than hand-written — payload-shape drift is the class of bug they exist to catch.

Two live checks need a running Marvin (MARVIN_* in the environment):

# End-to-end wiring: env, auth, chrome, a repository, one entry.
npx vite-node examples/smoke.ts [collection-slug]

# Field-level diff against a site's existing hand-rolled integration.
npx vite-node -c examples/parity/vite.config.ts examples/parity/mashandburnco.ts

License

MIT