Sub-skills

SEO and feeds in depth

For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill seo`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/seo.md verbatim with curl; don't summarize them).
Run `cubek check` after every edit until it is clean.

Search engines, social cards and feeds for server-rendered pages: meta tags, canonical URLs, OpenGraph, JSON-LD, Atom/RSS, sitemap, robots.txt.

// schema.ts
import { table, t } from "cubek"

export const posts = table("posts", { id: t.id(), slug: t.string().unique(), title: t.string(), excerpt: t.string(), body: t.string(), author: t.string(), createdAt: t.timestamp().default("now") }).index("createdAt")
// src/app.tsx
import { app, db, jsonLd, markdown, raw } from "cubek"

const xml = (s: string): string => s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;")

app.get("/p/:slug", async (c) => {
  const post = await db.posts.findOne({ where: { slug: c.param("slug") } })
  if (post === null) return c.notFound()
  const url = `${c.origin()}/p/${post.slug}`
  return c.render(
    <html lang="en">
      <head>
        <meta charset="utf-8" /><title>{post.title}</title>
        <meta name="description" content={post.excerpt} /><link rel="canonical" href={url} />
        <meta property="og:type" content="article" /><meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} /><meta property="og:url" content={url} />
        <link rel="alternate" type="application/atom+xml" href="/feed.xml" />
        {jsonLd({ "@context": "https://schema.org", "@type": "BlogPosting", headline: post.title, datePublished: post.createdAt, author: { "@type": "Person", name: post.author } })}
      </head>
      <body><article><h1>{post.title}</h1>{raw(markdown.render(post.body))}</article></body>
    </html>,
  )
})

app.get("/feed.xml", async (c) => {
  const o = c.origin()
  const list = await db.posts.find({ orderBy: { createdAt: "desc" }, limit: 20 })
  const entries = list.map((p) => `<entry><title>${xml(p.title)}</title><link href="${o}/p/${p.slug}"/><id>${o}/p/${p.slug}</id><updated>${p.createdAt}</updated><author><name>${xml(p.author)}</name></author><content type="html">${xml(markdown.html(p.body))}</content></entry>`)
  const feed = `<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Blog</title><id>${o}/</id><link href="${o}/"/><updated>${list.length > 0 ? list[0].createdAt : new Date().toISOString()}</updated>${entries.join("")}</feed>`
  return c.text(feed, 200, { "content-type": "application/atom+xml; charset=utf-8" })
})

app.get("/sitemap.xml", async (c) => {
  const list = await db.posts.find({ orderBy: { createdAt: "desc" }, limit: 1000 })
  const urls = list.map((p) => `<url><loc>${c.origin()}/p/${p.slug}</loc><lastmod>${p.createdAt}</lastmod></url>`)
  return c.text(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}</urlset>`, 200, { "content-type": "application/xml" })
})

app.get("/robots.txt", async (c) => c.text(`User-agent: *\nAllow: /\nSitemap: ${c.origin()}/sitemap.xml\n`))
  • markdown.html(md) is the sanitized HTML as a string (feeds, mail html); markdown.render is SafeHtml for raw() in templates. Interpolating a SafeHtml or JSX into a string is a compile error.
  • Content types: Atom application/atom+xml, RSS application/rss+xml, sitemap application/xml, robots text/plain (the c.text default).
  • Atom takes the ISO timestamps as they are. RSS 2.0 needs RFC 822 dates (Tue, 01 Sep 2026 10:00:00 GMT, built by hand from toISOString()) and the HTML escaped as above: <description>${xml(markdown.html(p.body))}</description>.
  • Absolute URLs from c.origin() (canonical, og:url, og:image, feeds).
  • jsonLd(data) escapes the JSON; never a <script> with inline content.
  • Social cards: og:image 1200x630 in public/ (c.origin() + asset("og.png")) and <meta name="twitter:card" content="summary_large_image" />.
  • A page rendered to a string: await (await c.render(<Page />)).text().