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

generated-web-client

v1.12.0

Published

SSR-safe client for the Generated public content API, with utilities for content processing, image URLs, sitemaps and SEO schema.

Downloads

500

Readme

Generated Web Client

TypeScript client for the Generated public content API, plus the helpers a site needs around it: markdown preprocessing, responsive imgproxy URLs, CTA placement, sitemaps and JSON-LD.

Generated hosts AI-written blog, news and glossary content for a project; your site fetches it and renders it. This package is the fetching-and-rendering half. It has no runtime dependencies, is safe to import in server components, edge runtimes and browsers, and keeps its key-handling code in a separate /node entrypoint so signing keys cannot leak into a client bundle.

Requires Node.js 24 or newer.

Installation

npm install generated-web-client

The API it talks to

Everything is served from a single host, unauthenticated, keyed by your project ID:

https://api.generated.app/api/v1/{projectId}/…

| Endpoint | Returns | |---|---| | GET /blog/list | Paginated blog list items, newest first | | GET /blog/slug/{slug} | One blog post | | GET /blog/id/{postId} | One blog post | | GET /news/list, /news/slug/{slug}, /news/id/{id} | News | | GET /glossary/list, /glossary/slug/{slug}, /glossary/id/{id} | Glossary terms | | GET /sitemap/, /sitemap/{kind}/{page} | XML sitemaps |

Post endpoints take ?format=markdown or ?format=html (html is the default), plus ?locale=. Responses are wrapped in { "data": … }; this client unwraps them and throws GeneratedAPIError (carrying status, code and requestId) on failure.

Quick start

import {
  GeneratedBlogClient,
  preprocessGeneratedMarkdown,
  generateImgproxySrcset,
  buildArticleSchema,
} from "generated-web-client";

const blog = new GeneratedBlogClient({
  baseUrl: "https://api.generated.app",
  projectId: "your-project-id",
  defaultFormat: "markdown",
});

const post = await blog.getBySlug("my-post", { locale: "en" });

// Resolve genimg:/// and gen_video: syntax to real URLs
const markdown = preprocessGeneratedMarkdown(post.markdown, {
  projectId: "your-project-id",
  postType: "blog",
  postId: post.id,
});

const { src, srcSet } = generateImgproxySrcset({ src: post.cover_image_url, quality: 75 });

const schema = buildArticleSchema({
  url: `https://example.com/blog/${post.slug}`,
  title: post.title,
  description: post.meta_description,
  publishedAt: post.published_at,
  modifiedAt: post.updated_at,
  hideDates: post.hide_dates,
  images: [post.cover_image_url],
});

Dates

Read this before rendering a date anywhere. Blog payloads carry three date-related fields, on /blog/list and /blog/slug/{slug} alike, in both response formats:

| Field | Meaning | |---|---| | published_at | When the post was scheduled to appear. On a corpus seeded in one campaign this is an artifact of scheduling, not a fact about the article. | | updated_at | Real last-modification time of this post in this locale. The honest input for dateModified and for sitemap <lastmod>. | | hide_dates | Project-level switch: this blog is a dateless corpus. Identical on every post of the project. |

updated_at and hide_dates are typed optional: an API older than 2026-07-25 does not send them, and a site pinned to one degrades to showing dates rather than breaking.

The rules

  1. When hide_dates is true, no date reaches a human — article header, listing cards, related-post widgets, home-page blocks, RSS <pubDate>, article:published_time. Nothing takes its place: no "updated recently", no reading time as a substitute, no relative phrasing. The absence is the point.
  2. In JSON-LD, datePublished and dateModified are omitted — the keys are absent, not "", not null, not 0001-01-01T00:00:00Z. An empty value in a structured-data date field reads as malformed rather than as absent, and validators treat it as an error.
  3. Sitemap <lastmod> survives, and comes from updated_at. It is not a visible date, crawlers depend on it, and published_at is a scheduling artifact that does not belong in a freshness signal.
  4. Order never changes. The API returns posts newest-first, server-side. Do not sort client-side by a date field: hiding dates must remove the label and nothing else. No helper in this package sorts.
  5. Blog only. News and glossary are time-anchored by definition, carry no hide_dates, and keep their dates.

Ask one module rather than re-deriving the rule per component — the surfaces that get missed in practice are the RSS feed and post blocks outside /blog.

import {
  areDatesHidden,
  visiblePublishedDate,
  formatVisiblePublishedDate,
  structuredDataDates,
  sitemapLastmod,
  resolveBlogDates,
} from "generated-web-client";

areDatesHidden(post);                                  // boolean
visiblePublishedDate(post);                            // string | undefined
formatVisiblePublishedDate(post, { locale: "en-US" }); // "" when hidden
structuredDataDates(post);                             // {} when hidden
sitemapLastmod(post);                                  // from updated_at, never hidden
resolveBlogDates(post);                                // all four at once

Worked example: hide_dates: false

const post = {
  published_at: "2026-01-15T10:00:00Z",
  updated_at: "2026-07-20T09:30:00Z",
  hide_dates: false,
};

formatVisiblePublishedDate(post, { locale: "en-US" });
// => "Jan 15, 2026"          → render it in the header and on cards

structuredDataDates(post);
// => { datePublished: "2026-01-15T10:00:00Z", dateModified: "2026-07-20T09:30:00Z" }
//    dateModified is updated_at, not a repeat of published_at

sitemapLastmod(post);
// => "2026-07-20T09:30:00Z"

Worked example: hide_dates: true

const post = {
  published_at: "2026-01-15T10:00:00Z",
  updated_at: "2026-07-20T09:30:00Z",
  hide_dates: true,
};

formatVisiblePublishedDate(post, { locale: "en-US" });
// => ""                      → render nothing, substitute nothing

structuredDataDates(post);
// => {}                      → both keys absent from the JSON-LD

sitemapLastmod(post);
// => "2026-07-20T09:30:00Z"  → unchanged; lastmod is not a visible date

The JSON-LD builder takes the flag directly, so the article object is built the same way in both states:

const schema = buildArticleSchema({
  url: postUrl,
  title: post.title,
  description: post.meta_description,
  publishedAt: post.published_at,
  modifiedAt: post.updated_at,
  hideDates: post.hide_dates, // omits both date keys when true
  images: [post.cover_image_url],
  publisher: { name: "My Company", logo: "https://example.com/logo.svg" },
});

Everything else in the schema — headline, author, publisher, image, mainEntityOfPage, inLanguage — is unaffected, so the object still validates as an Article.

For Open Graph metadata, map the same two values:

const { datePublished, dateModified } = structuredDataDates(post);
const openGraph = {
  type: "article",
  ...(datePublished && { publishedTime: datePublished }),
  ...(dateModified && { modifiedTime: dateModified }),
};

And for a sitemap you build yourself:

import { buildSitemapEntries } from "generated-web-client";

// app/sitemap.ts
const { items } = await blog.list({ limit: 100, locale: "en" });
return buildSitemapEntries(items, { siteUrl: "https://example.com" });
// => [{ url: "https://example.com/blog/my-post", lastModified: "2026-07-20T09:30:00Z" }, …]
//    input order preserved, lastModified from updated_at, present either way

If you serve sitemaps from Generated instead (GeneratedSitemapClient below), there is nothing to do — the API already builds them this way.

API clients

GeneratedBlogClient

const client = new GeneratedBlogClient({
  baseUrl: "https://api.generated.app",
  projectId: "your-project-id",
  defaultLocale: "en",
  defaultFormat: "markdown", // or "html"
  cta: "auto",               // or "none"
});

// Newest first, server-side. Render in this order.
const { items, total, page, limit } = await client.list({ page: 1, limit: 20, locale: "en" });

const post = await client.getBySlug("my-post", { format: "markdown" });
const same = await client.getById("post-uuid");

GeneratedNewsClient / GeneratedGlossaryClient

Same interface, minus the CTA options:

import { GeneratedNewsClient, GeneratedGlossaryClient } from "generated-web-client";

const news = new GeneratedNewsClient({ baseUrl: "https://api.generated.app", projectId: "your-project-id" });
const glossary = new GeneratedGlossaryClient({ baseUrl: "https://api.generated.app", projectId: "your-project-id" });

GeneratedSitemapClient

Fetches XML sitemaps for proxying through your own domain:

import { GeneratedSitemapClient } from "generated-web-client";

const sitemaps = new GeneratedSitemapClient({
  baseUrl: "https://api.generated.app",
  projectId: "your-project-id",
});

const indexXml = await sitemaps.getIndex();
const blogXml = await sitemaps.getBlogPage(1);

URL builders

Build URLs for images and videos embedded in generated content:

import { buildGeneratedImageUrl, buildGeneratedVideoUrl } from "generated-web-client";

buildGeneratedImageUrl({
  projectId: "your-project-id",
  postType: "blog",
  postId: "post-uuid",
  filename: "hero.webp",
  host: "https://img.example.com",
});
// => "https://img.example.com/your-project-id/blog/post-uuid/hero.webp"

Omit host and the URL is built against the media host your Generated deployment serves images from.

Markdown preprocessor

Converts Generated-specific markdown syntax to standard markdown:

import { preprocessGeneratedMarkdown } from "generated-web-client";

const processed = preprocessGeneratedMarkdown(post.markdown, {
  projectId: "your-project-id",
  postType: "blog",
  postId: post.id,
});

Supported syntax:

  • ![alt](genimg:///filename) — generated images
  • ![alt](genvid:///filename) — generated videos
  • [gen_img:filename] or {{gen_img:filename}} — inline image tags
  • [gen_video:youtube:ID] or [gen_video:URL] — video embeds

Images (imgproxy)

Client-side (unsigned)

import { imgproxyLoader, generateImgproxySrcset } from "generated-web-client";

const url = imgproxyLoader({ src: post.cover_image_url, width: 800, quality: 75 });
const { src, srcSet } = generateImgproxySrcset({ src: post.cover_image_url, quality: 75 });
// <img src={src} srcSet={srcSet} sizes="(max-width: 768px) 100vw, 800px" />

Server-side (signed)

Signing keys must never reach a browser bundle, so the signer lives in a separate entrypoint. Import it only from server-side modules.

import {
  createImgproxySignerSync,
  imgproxySigningConfigFromEnv,
  generateSignedImgproxySrcset,
} from "generated-web-client/node";

const signer = createImgproxySignerSync(
  imgproxySigningConfigFromEnv(process.env as Record<string, string | undefined>),
);

const { src, srcSet } = generateSignedImgproxySrcset({ src: post.cover_image_url }, signer);

Or, with a custom image domain, the one-call factory:

import "server-only";
import { createImageHelpers } from "generated-web-client/node";

export const images = createImageHelpers({
  key: process.env.IMGPROXY_KEY!,
  salt: process.env.IMGPROXY_SALT!,
  host: "img.example.com",
});

const { src, srcSet } = images.getSrcset(post.cover_image_url);

Environment variables:

  • IMGPROXY_KEY — hex-encoded signing key (server-only)
  • IMGPROXY_SALT — hex-encoded signing salt (server-only)
  • IMGPROXY_SIGNATURE_SIZE — signature size in bytes (default: 8)

Content utilities

import {
  estimateReadingMinutes,
  formatPublishedDate,
  extractHeadings,
  slugify,
  stripMarkdown,
  truncateText,
} from "generated-web-client";

estimateReadingMinutes(post.markdown);                       // => 5
formatPublishedDate(post.published_at, { locale: "en-US" }); // => "Jan 15, 2026"
extractHeadings(post.markdown);                              // => [{ level: 2, text, slug }, …]
extractHeadings(post.markdown, { levels: [2, 3] });
slugify("Hello World!");                                     // => "hello-world"
stripMarkdown("This is **bold**");                           // => "This is bold"
truncateText("A long sentence that needs cutting", 20);

For blog posts prefer formatVisiblePublishedDate over formatPublishedDate: it respects hide_dates.

CTAs

Blog payloads can carry cta (a full CTA chosen by the API) and adaptive_ctas (post-specific variants without a link). These helpers validate them and place them through the article.

import { normalizeCTA, isValidCTA, toFullCTA, pickRandomFullCTAs } from "generated-web-client";

const cta = normalizeCTA(post.cta);  // null when invalid
const full = toFullCTA(adaptive, { variant: "dark", button_href: "/signup" });
const chosen = pickRandomFullCTAs(post.adaptive_ctas ?? [], 3, { button_href: "/signup" });

Placement, in one call:

import { prepareContentWithCTAs } from "generated-web-client";

const { sections, ctas } = prepareContentWithCTAs(post.markdown, post.adaptive_ctas ?? [], {
  defaults: { button_href: "/signup", variant: "light" },
  headingsPerCTA: 3,
  maxCTAs: 3,
});

sections.map((section, i) =>
  section.type === "content" ? (
    <Markdown key={i}>{section.markdown}</Markdown>
  ) : (
    <InlineCTA key={i} cta={ctas[section.slotIndex]} />
  ),
);

The individual steps — extractHeadings, calculateCTAPlacements, splitContentWithCTASlots, pickRandomFullCTAs — are exported too if you want control over any of them.

JSON-LD

import {
  buildArticleSchema,
  buildFAQSchema,
  buildBreadcrumbSchema,
  buildOrganizationSchema,
  buildWebSiteSchema,
  cleanJsonLd,
  jsonLdToScript,
} from "generated-web-client";

const article = buildArticleSchema({ /* see the Dates section above */ });
const faq = buildFAQSchema(post.faq);
const crumbs = buildBreadcrumbSchema([
  { name: "Home", url: "https://example.com" },
  { name: "Blog", url: "https://example.com/blog" },
  { name: post.title, url: postUrl },
]);

const scriptTag = jsonLdToScript(article);
// => '<script type="application/ld+json">{"@context":"https://schema.org",…}</script>'

cleanJsonLd strips undefined values recursively, which some frameworks require before serializing.

TypeScript

Every type is exported:

import type {
  BlogListItem,
  BlogPostMarkdown,
  BlogPostHTML,
  NewsListItem,
  GlossaryTermListItem,
  Paginated,

  BlogDateFields,
  StructuredDataDates,
  BlogDatePolicy,
  SitemapEntry,

  ArticleSchemaInput,
  PreprocessMarkdownOptions,
  ImgproxySrcsetOptions,
  ExtractedHeading,
  JsonLd,
} from "generated-web-client";

Development

npm ci
npm run type-check
npm test
npm run build

License

MIT — see LICENSE.