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

@jrcodex/seo-kit

v0.6.1

Published

Render posts from the JRCodex SEO engine inside your own site: typed client, Next/vinext metadata helpers, media proxy.

Readme

@jrcodex/seo-kit

Render posts from the JRCodex SEO engine inside your own site, in your own design. The engine writes and your client approves; this package fetches the result for your pages to lay out.

npm install @jrcodex/seo-kit

Wire it up (Cloudflare Worker in the same account)

// wrangler.jsonc
"services": [{ "binding": "SEO", "service": "seo-engine", "entrypoint": "Sites" }]
wrangler secret put SEO_SITE_KEY     # the sk_... key from "Connect a website"
// lib/seo.ts
import { env } from "cloudflare:workers";
import { rpc } from "@jrcodex/seo-kit";
export const seo = () => rpc(env.SEO, env.SEO_SITE_KEY);

A post page (Next.js App Router / vinext)

// app/[locale]/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { postBodyProps, postJsonLd, postMetadata } from "@jrcodex/seo-kit/next";
import { seo } from "@/lib/seo";

export const revalidate = 300;

export async function generateMetadata({ params }) {
  const { locale, slug } = await params;
  const post = await seo().post(locale, slug);
  return post ? postMetadata(post) : {};
}

export default async function Page({ params }) {
  const { locale, slug } = await params;
  const post = await seo().post(locale, slug);
  if (!post) notFound();
  return (
    <YourArticleLayout title={post.title} hero={post.hero} category={post.category}>
      <script type="application/ld+json" dangerouslySetInnerHTML={postJsonLd(post)} />
      <div className="prose" {...postBodyProps(post.html)} />
    </YourArticleLayout>
  );
}

That is the quickest version: the body as one block of filtered HTML. The section below is the one that makes each post look laid out by hand.

Say what your site is

The engine adapts to what a site has, so declare it once, in code, and tell the engine on cold start. The dashboard then shows only the modules you have, in your business's words ("Clients" for a service business, "Customers" for a shop), and every post is composed with what you can render.

// lib/seo.ts
import type { Manifest } from "@jrcodex/seo-kit";

export const manifest: Manifest = {
  version: 1,
  kind: "service",                                  // service | shop
  has: { blog: true, intake: true },                // modules this site has
  renders: { aside: false, placements: ["wide", "inset-right"] },   // what your components can show
  locales: ["en", "es"],
  intake: { fields: ["name", "phone", "email", "message"] },       // the only fields an inquiry may carry
};
// worker/index.ts
import { announceOnce } from "@jrcodex/seo-kit";
ctx.waitUntil(announceOnce(seo(), manifest));     // one call per isolate, retried if it fails

Name features in your own words. portfolio, projects and our_work are the gallery, quote_form and contact_form the intake form, and your owner sees your word ("Portfolio"). A word the engine does not recognise is left out, never refused, and a person decides what it is. announce() returns unrecognized saying what became of each. A malformed manifest (a value that is not true or false, say) is still refused with a ManifestError naming the part. renders has the same shape compose() takes: compose(post, { supports: manifest.renders }).

Your owner's dashboard, in your site's look

Send your site's theme (platform-theme contract v1) as theme in the manifest. The engine checks every value and every contrast pair (contract v1): an accepted theme styles your owner's dashboard, and one that fails is dropped - the dashboard keeps the platform's look - while the rest of the manifest is kept. announce() returns theme: { status: "accepted" } or { status: "dropped", reason }.

Commit the theme file to your source tree - lib/dashboard-theme.json, say - and regenerate it with a script. Do NOT import it from dist/: a clean checkout has no dist/, so tsc fails (TS2307) before anything is built, and vinext build empties dist/ before it bundles, so the import is unresolved anyway (found by montano-btm, 2026-09-26).

import type { DashboardTheme, Manifest } from "@jrcodex/seo-kit";
import theme from "./dashboard-theme.json"; // lib/, committed; `npm run dashboard-theme` regenerates it
// A JSON import widens `version: 1` to number; the engine checks the real thing.
export const manifest = { version: 1, kind: "service", has: { blog: true }, theme: theme as DashboardTheme } satisfies Manifest;

Add a test that regenerates the theme and fails if it differs from the committed file, so the file never drifts from your site's CSS.

Take an inquiry

Keep your own form, in your own design. Verify your bot check, then hand the submission to the engine; it keeps only the fields your manifest declared and shows the business a new inquiry first thing on their dashboard.

// worker/index.ts, or a route handler
import { InquiryRefused } from "@jrcodex/seo-kit";

if (!(await turnstileOk(request))) return new Response("Please try the check again", { status: 400 });
const form = await request.formData();
try {
  await seo().inquire({
    locale: "en",
    page: "/contact",
    fields: Object.fromEntries(form),      // undeclared fields are dropped by the engine
  });
  return Response.redirect("/contact/thanks", 303);
} catch (err) {
  if (err instanceof InquiryRefused) return new Response(err.message, { status: err.retryAfterSeconds ? 429 : 400 });
  throw err;
}

Each site may send 60 an hour; above that the engine answers "try again in a minute" (retryAfterSeconds) rather than dropping the person. Inquiries are personal data: the engine deletes them a year after they arrive.

instanceof InquiryRefused works from 0.4.1 on: Workers RPC keeps an error's name but not its class, so the kit rebuilds its own class from the name (SiteKeyError and ManifestError too). retryAfterSeconds crosses the binding only with enhanced error serialization: give the site's Worker a compatibility_date of 2026-04-21 or later, or the enhanced_error_serialization compatibility flag.

A picture from a recipe (0.6.0)

A recipe is a tool the business keeps on the engine: Montano's visualizer takes a photo of a room and a tile swatch and makes the room with that tile. Your site keeps its own form in its own design. A route on your Worker hands the engine the photos and gets back a run:

// app/api/visualize/route.ts
import { RecipeRefused, runRecipe } from "@jrcodex/seo-kit";
import { seo } from "@/lib/seo";

export async function POST(request: Request) {
  const form = await request.formData();
  if (!(await turnstileOk(form, request))) return Response.json({ error: "Please try the check again." }, { status: 400 });
  if (!(await visitorAllowed(request))) return Response.json({ error: "Please wait a little and try again." }, { status: 429 });

  const room = form.get("room");
  const tile = form.get("tile");
  if (!(room instanceof File) || !(tile instanceof File)) return Response.json({ error: "Add both photos." }, { status: 400 });
  try {
    const run = await runRecipe(seo(), "visualizer", {
      room: { image: await room.arrayBuffer() },
      tile: { image: await tile.arrayBuffer() },
    });
    return Response.json(run);     // a RecipeRun: done, failed, or still running after 90 s
  } catch (err) {
    if (err instanceof RecipeRefused) return Response.json({ error: err.message }, { status: err.retryAfterSeconds ? 429 : 400 });
    throw err;
  }
}

and on the page:

{run.status === "done" && run.result?.kind === "image" && (
  <figure>
    <img src={run.result.src} alt="Your room with the tile you chose" />
    <figcaption>{run.disclaimer}</figcaption>
  </figure>
)}
{run.status === "failed" && <p>{run.error}</p>}

runRecipe() starts the run and polls it every 1.5 s for up to 90 s. To answer at once instead, call seo().startRecipe(...), return the run (it says "running"), and let the page poll a second route that returns seo().recipeRun(id).

What is yours and what is the engine's:

  • Turnstile and per-visitor limits are your site's job, in front of the call. The engine never sees a visitor, only your site key. It holds the business's daily ceiling (50 runs in any 24 hours, which bounds the model bill whatever happens) and past it refuses with RecipeRefused and a retryAfterSeconds of an hour.
  • Show disclaimer under the picture, every time. It is the business's own line, e.g. "An illustration, not a quote."
  • Serve result.src with mediaHandler (below): it is /media/... on your origin. It comes with cache-control: private, max-age=86400, so the visitor's browser keeps it a day and no shared cache keeps it at all: it is a picture of someone's home. Do not put it through /cdn-cgi/image/, whose cache is shared.
  • The visitor's own photos go to the model and are never served back. The engine deletes them, and the pictures made from them, after 30 days.
  • A refusal's message is plain enough to show: "Add a photo for room.", "The file for tile is not a photo we can use (JPEG, PNG, WebP or HEIC)." The engine reads a photo's type from its bytes, and takes up to 10 MB a photo and 24 MB a run. A call over 32 MiB never reaches the engine (the limit of one call over the binding), so hold uploads to size in the page.
  • http() cannot run recipes: the engine has no public address, so its startRecipe and recipeRun throw. Use rpc(env.SEO, key).
  • retryAfterSeconds crosses the binding only with enhanced error serialization, as for inquiries: a compatibility_date of 2026-04-21 or later, or the enhanced_error_serialization flag.

The business's details and photos

The owner keeps the business's name, phone, email, address, social links and the places it serves on the dashboard. Read them rather than hard-coding them: the engine's LocalBusiness markup and its posts read the same record, so your footer cannot drift from either.

const profile = await seo().profile();

// E.164 ("+18327180760"): link it as-is, format it for display yourself.
{profile.telephone && <a href={`tel:${profile.telephone}`}>{formatPhone(profile.telephone)}</a>}
<p>Serving {profile.serviceArea.join(", ")}</p>

Photos of finished work come the same way, in the owner's order. The list is empty until the business adds photos on the dashboard, so keep your own as the fallback. Their URLs are root-relative like post images: serve /media/* with mediaHandler (below).

const photos = await seo().gallery();
const shown = photos.length > 0 ? photos : ownPhotos;

Checking the URLs yourself? Use srcsetUrls(image.srcset): the transform URLs contain commas (format=auto,width=768), so splitting a srcset on "," cuts them apart.

The post, laid out as planned

Every post carries a layout plan made when it was written: how it opens, which figure gets a caption and how wide, one or two lines lifted into pull quotes, a key-points box, a tip or warning aside, what the ending asks for. The engine decided those; your components decide what they look like. compose() turns the plan into an ordered list to render:

import { compose } from "@jrcodex/seo-kit/compose";

<YourArticleLayout category={post.category}>
  {compose(post).map((item, i) => {
    switch (item.kind) {
      case "opening":    return <Opening key={i} treatment={item.treatment} title={item.title} excerpt={item.excerpt} hero={item.hero} />;
      case "html":       return <div key={i} className="prose" dangerouslySetInnerHTML={{ __html: item.html }} />;
      case "figure":     return <Figure key={i} placement={item.placement} caption={item.caption} html={item.html} />;
      case "pull-quote": return <PullQuote key={i}>{item.text}</PullQuote>;
      case "key-points": return <KeyPoints key={i} items={item.items} />;
      case "aside":      return <Aside key={i} note={item.note}>{item.text}</Aside>;
      case "closing":    return <Closing key={i} cta={item.cta} tone={item.tone} />;
    }
  })}
</YourArticleLayout>

| item | what it carries | |---|---| | opening | treatment: hero-full, hero-inset, statement (no image, the excerpt large) or none; plus title, excerpt, hero | | html | one top-level block of the body, in order (index into post.blocks) | | figure | a body figure with placement (wide, inset-left, inset-right) and an optional caption that adds a fact | | pull-quote | a sentence copied word-for-word from the body | | key-points | 2–6 short points the body states | | aside | 1–2 sentences with a note: tip, warning or cost | | closing | what the end asks for: call, schedule, read-more or none; tone for styling |

Tell it what you have and it composes around it:

compose(post, { supports: { aside: false, placements: ["wide"] } })

drops asides and renders every figure wide. Items you skip cost nothing: the html and figure items alone are the complete body. Old posts and posts whose plan did not pass the engine's checks arrive with the neutral plan (hero on top, body as written), so a page never fails because its art direction did. A site with its own judgement can replace compose() and keep the item shapes.

layoutFor(post, ["wide", "narrow", "split"]) still picks one of your page templates per post, deterministically, if you also want variety at that level.

The list and the sitemap

const posts = await seo().posts(locale);   // newest first, with hero, category, readingMinutes
// app/sitemap.ts
import { sitemapEntries } from "@jrcodex/seo-kit/next";
export default async function sitemap() {
  return [...ownPages, ...sitemapEntries(await seo().sitemap(), "https://acme.com")];
}

Images

Post images are root-relative on your origin. Serve them from your Worker:

// worker/index.ts
import { mediaHandler } from "@jrcodex/seo-kit/worker";
const media = await mediaHandler(request, seo());
if (media !== null) return media;

Cloudflare's image transformation on your zone then resizes them with no further configuration.

Rules the engine relies on

  • Your locale routing must produce the engine's paths: the business's first language unprefixed (/blog/x), the others prefixed (/es/blog/y). Assert it in a test: post.locales[0] === routing.defaultLocale.
  • Render post.html, or the html and figure items from compose(), as-is. They are filtered by the engine; do not re-sanitize or re-parse them. Pull quotes, key points, asides and captions are plain text: escape them as you would any prop.
  • Never set hreflang or canonical by hand: postMetadata derives them from the translation group, so both languages always point at each other.

Outside Cloudflare

http("https://engine.example", key) speaks the same contract over HTTPS with the key as a bearer token.

Projects (0.5.0)

profile().team (0.5.1) says who does the work: "solo" means write "I", "crew" or null means "we". Read your hero, footer and about copy from it, so one profile change reaches every page.

One job, in the owner's words, with its photos - each a page. The owner makes them on the dashboard; your site shows them:

const list = await seo().projects("en");          // newest first
const one  = await seo().project("en", slug);     // null when there is none

ProjectSummary carries title, summary, service (a taxonomy category: link it to posts with the same category), locality, city, completedOn, cover (a ProjectPhoto with src/srcset/sizes, served through mediaHandler like post images), photoCount, path (/work/<slug>, or /<locale>/work/<slug> outside the default locale) and alternates for hreflang. Project adds bodyHtml (sanitised by the engine) and photos, each with a role of before, after or detail, in the owner's order. A project with no write-up in the locale you ask for comes back in the site's default language, and says so in locale.

Declare it in your manifest as has: { projects: true } (or your own word: portfolio, our_work), and the owner's dashboard gains the Projects page.

The customer chat, the FAQ and llms.txt (0.6.0)

A chat on your site that answers from what the owner chose to show, and ends, when the visitor wants, in the same inquiry your form sends. Declare it as has: { chat: true }; the owner turns it on from his dashboard (it is off until he does).

import { chatHandler, llmsTxtHandler } from "@jrcodex/seo-kit/worker";

// 1. Start: YOUR route, after your bot check, exactly as for the form.
//    POST /chat/start { turnstileToken, locale } -> { conversationId, expiresAt }
const started = await seo().startChat(locale);   // throws ChatRefused: show the form

// 2. Connect and clear: the kit hands /chat/<id> to the engine with your key
//    and none of the browser's cookies. GET is the WebSocket, DELETE is "Clear chat".
const chat = await chatHandler(request, seo());
if (chat !== null) return chat;

// 3. /llms.txt for AI crawlers, from the profile, the shown answers, projects and posts.
const llms = await llmsTxtHandler(request, seo());
if (llms !== null) return llms;

The browser uses the Agents SDK's chat client against your own path: useAgentChat with useAgent({ agent: "customer-chat-agent", basePath: "chat/" + conversationId }). Keep the conversation in the browser (IndexedDB) for its seven days, with a "Clear chat" button that sends the DELETE; the engine's copy is deleted after seven days either way.

  • Suggested questions and the FAQ section: faq(locale) returns the owner's shown questions and answers, pinned first, then the most asked. Render an FAQ section with FAQPage markup, and show the first few as chips in the chat. A chip sends its question's exact text; the engine answers it from storage with no model call.
  • The lead card: when the visitor wants a visit or a quote, the chat's offer_inquiry tool part carries { fields } named by your intake.fields. Show it as an editable card; on Send, submit it through your own form route with source: "chat".
  • Refusals: ChatRefused when the site has no chat, the owner has it off, the month's budget is spent or the day's conversations are used up. Its message is plain enough to show beside the form.

Over http() the chat and its FAQ are not available: the engine keeps no public address for them.

Photos with the request (0.6.1)

When a visitor uses the visualizer and then asks for a quote, offer to send their photos with it, on by default and plainly worded: "Your photos go with your request." If they keep it on, put the run's id on the submission:

await seo().inquire({ locale, page, fields, recipeRunId: run.id });

The owner sees their photos and the picture they saw on that inquiry, behind sign-in. They are never on a public page, and they are kept as long as the inquiry. Without recipeRunId a visitor's photos are deleted after 30 days and the owner never sees them.