One import → every SEO surface an ecommerce site needs, generated as pure strings. Framework-agnostic, zero-dependency, Cloudflare-safe.
CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.
bun add @suluk/seo
A complete SEO toolkit (inspired by the Nuxt SEO suite) as a set of pure builder functions — each takes plain input and returns a string (or a structured object you render). No runtime dependencies, no node:fs, no argless new Date() — so it runs identically in Node, Bun, the browser, and a Cloudflare Worker. It generates:
robots.txt — user-agent groups (allow/disallow/crawl-delay) + Sitemap: / Host: directives.sitemap.xml with image entries and hreflang alternates, plus a sitemap-index. XML is escaped and priority is clamped to [0,1].@graph script.<head> HTML string.llms.txt (llmstxt.org) — a curated, LLM-friendly map of the site.image/svg+xml).manifest.json string with sane defaults.Reach for it when a Suluk app (or any public site) needs its SEO/discovery surfaces — /robots.txt, /sitemap.xml, /llms.txt, <head> meta, JSON-LD, an OG image, a web manifest. You drive each builder from your own data (the seed catalog, the request, the configured site origin) and write the result to a route/file. It does not crawl, fetch, or host anything — it only generates the bytes; serving them is your route handler's job (see Boundary).
Every export is a plain function. A few common surfaces, as you'd wire them into Astro/Hono routes:
import { robotsTxt } from "@suluk/seo";
const body = robotsTxt({
groups: [{ userAgent: "*", allow: ["/"], disallow: ["/account", "/checkout", "/api/"] }],
sitemaps: ["https://example.com/sitemap.xml"],
host: "example.com",
});
// → "User-agent: *\nAllow: /\nDisallow: /account\n…\nSitemap: https://example.com/sitemap.xml\n"
import { sitemapXml, sitemapIndex, type SitemapUrl } from "@suluk/seo";
const urls: SitemapUrl[] = [
{ loc: "https://example.com/", changefreq: "daily", priority: 1.0 },
{
loc: "https://example.com/products/founder-tee",
changefreq: "weekly",
priority: 0.8,
images: [{ loc: "https://example.com/img/founder-tee.jpg", title: "Founder Tee" }],
alternates: [{ hreflang: "ar", href: "https://example.com/ar/products/founder-tee" }],
},
];
const xml = sitemapXml(urls); // <urlset> with image + xhtml namespaces auto-added
const index = sitemapIndex([{ loc: "https://example.com/sitemap-products.xml", lastmod: new Date() }]);
seoTags() returns Tag[] descriptors (so a framework can render them however it likes); renderTags() turns them into a plain <head> HTML string.
import { seoTags, renderTags, resolveTitle } from "@suluk/seo";
const tags = seoTags({
title: "Store",
titleTemplate: "%s — saasuluk", // → "Store — saasuluk"
description: "Shop the catalog",
canonical: "https://example.com/products",
url: "https://example.com/products",
image: "https://example.com/og.svg",
imageAlt: "saasuluk",
siteName: "saasuluk",
locale: "en",
twitterCard: "summary_large_image",
themeColor: "#6366f1",
robots: { maxImagePreview: "large" }, // or `noindex: true` to force "noindex, nofollow"
alternates: [{ hreflang: "ar", href: "https://example.com/ar/products" }],
});
const headHtml = renderTags(tags); // "<title>…</title>\n<meta name=…>\n<link rel=\"canonical\" …>"
resolveTitle("Store", "%s — saasuluk"); // "Store — saasuluk" (standalone title resolver)
@graphThe builders return plain nodes (empty fields dropped). graph() wraps several into one @graph script; ld() wraps a single node with @context for a standalone <script>.
import { graph, organization, website, breadcrumb, product, faqPage } from "@suluk/seo";
const jsonLd = graph(
organization({ name: "saasuluk", url: "https://example.com", logo: "https://example.com/logo.svg" }),
website({ name: "saasuluk", url: "https://example.com", searchUrl: "https://example.com/search?q={search_term_string}" }),
breadcrumb([{ name: "Home", url: "/" }, { name: "Store", url: "/products" }]),
product({
name: "Founder Tee",
sku: "TEE-001",
brand: "saasuluk",
image: "https://example.com/img/founder-tee.jpg",
offers: { price: 29, currency: "USD", availability: "InStock" }, // price → "29.00", availability → schema.org URL
rating: { ratingValue: 4.6, reviewCount: 12 },
}),
faqPage([{ question: "Do you ship worldwide?", answer: "Yes." }]),
);
const script = `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
Other node builders: article() (BlogPosting), itemList(), offer(), aggregateRating().
import { llmsTxt } from "@suluk/seo";
const txt = llmsTxt({
title: "saasuluk",
summary: "An ecommerce + SaaS starter projected from one OpenAPI v4 contract.",
details: "The products below are real slices of this repository.",
sections: [
{ title: "Docs", links: [{ title: "API reference", url: "/reference", description: "The live v4 contract" }] },
],
});
import { ogImageSvg } from "@suluk/seo";
const svg = ogImageSvg({ title: "Founder Tee", subtitle: "$29", brand: "saasuluk", eyebrow: "Store" });
// serve with content-type: image/svg+xml — defaults to 1200×630; override width/height/bg/fg/accent
import { webManifest } from "@suluk/seo";
const manifest = webManifest({
name: "saasuluk",
themeColor: "#6366f1",
icons: [{ src: "/icon.png", sizes: "512x512", type: "image/png" }],
}); // → a manifest.json string (display defaults to "standalone")
Stamp the deployment id into <head>, echo it from a light endpoint, and include the guard script once. When a newer deploy is detected, the next same-origin link click becomes a full page load — so a long-lived tab never runs stale HTML against rotated chunks.
import { deploymentMeta, skewGuardScript, DEPLOYMENT_HEADER } from "@suluk/seo";
// Server — in <head>:
deploymentMeta(BUILD_ID); // <meta name="x-deployment-id" content="…">
// …and on a health route, echo the same id:
app.get("/api/health", (c) => c.json({ ok: true }, 200, { [DEPLOYMENT_HEADER]: BUILD_ID }));
// Client — include once (inline <script>):
skewGuardScript(); // defaults: polls /api/health every 60s
| Export | Returns | Does |
|---|---|---|
robotsTxt(opts) |
string |
robots.txt from user-agent groups + sitemap/host directives |
sitemapXml(urls) / sitemapIndex(maps) |
string |
sitemap.xml (image + hreflang) / sitemap index |
seoTags(input) |
Tag[] |
structured head-tag descriptors (title/meta/link) |
renderTags(tags) |
string |
render Tag[] to SSR <head> HTML |
resolveTitle(title, template?) |
string |
apply a %s title template |
organization website breadcrumb product offer aggregateRating faqPage article itemList |
Node |
schema.org JSON-LD node builders |
graph(...nodes) / ld(node) |
Node |
wrap nodes in one @graph / a single node with @context |
llmsTxt(input) |
string |
the llmstxt.org site map |
ogImageSvg(input) |
string |
1200×630 branded social-card SVG |
webManifest(input) |
string |
a W3C manifest.json string |
deploymentMeta(id) / skewGuardScript(opts?) |
string |
skew-protection <meta> / inline guard script |
DEPLOYMENT_HEADER |
"x-deployment-id" |
the response header the guard polls |
This is an L3 generator — it emits owned bytes (strings) and stops there. It never crawls, fetches, hosts, or rasterizes: ogImageSvg returns an SVG string (rasterize to PNG with a renderer if a platform needs it), and serving every output — wiring /robots.txt, /sitemap.xml, /llms.txt, the <head>, /og.svg, the manifest — is your route handler's job. All inputs are injected by the caller: you pass the site origin, the catalog/seed data, and the deploy id (no ambient Date.now(), no env reads). Keep the IO at the app seam; keep the package pure.
Apache-2.0.