Skip to content
Hozu
Menu
All trials

Trial 0001 — writing a small blog with Tenon (AI authoring + SEO)

examples/blog: a public post index, two article pages, and a signed-in reading list (save/remove, 20-item limit). 17 KB of TypeScript.

Caveat: the author is the same AI that designed Tenon. This measures the tooling, not zero-shot learnability. A fresh-context agent writing the same app is the next, stronger test.

Authoring log

# What I wrote Caught by Outcome
F1 ui.each(post.body, 'length', …) over string[] not tsc ('length' is a key of string); validator TN008 Caught, but the cause read "Available: ." Lists of primitives cannot be iterated at all; I changed the data shape to { text }[]
F2 ui.a({ href: post.slug }), a relative URL, so the wrong link nothing Silent bug; found by reading. Building /posts/${slug} needs a registered fn (postPath)
F3 reading list shows both Save and Remove on every post nothing, and not expressible ui has no data-conditional node (only when on machine state), so "is this post saved?" cannot choose a button. Shipped as a UX defect
F4 one route and one view per article (article('hello-tenon') helper) n/a No route params: does not scale beyond a handful of posts
F5 7 transitions without contracts TN016 ×7 Correct and useful, but the human output hides the contract skeleton (only --json has snippet). 5 contracts later: 7/7
F6 home page render plan tenon plan One user-scoped aside makes the whole page per-request (cacheable: no); the public post list cannot be served from ISR on that page

What worked well: typed state names, exhaustive failed maps and invoke targets were right the first time; tenon validate located every problem it did catch at file:line; once contracts were written, the feature validated in one pass; the render plan and island set (1 island on /, 0 JS on article pages) were exactly what the principles promise.

Tally: 13 problems of 6 kinds surfaced (F1 twice, F5 seven times). Tools caught 9 problems of 2 kinds (TN008 ×2, TN016 ×7). Of the remaining 4 kinds, 1 was a silent bug (F2) and 3 were missing capabilities (F3, F4, F6).

SEO audit (pnpm --filter example-blog seo, against adapter-node)

19 / 45 checks pass.

Check / article pages Notes
200 + text/html, content without JS, one <h1>, internal links resolve ✔ ✔ Server-rendered HTML carries all content
JS shipped 2 scripts (1 island) 0 bytes ✔ as designed
user data kept out of cacheable HTML ✔ (bypass) ✔ (hit)
unknown URL → 404 ✔
HEAD answered ✖ 404 ✖ 404 Crawlers and link checkers use HEAD
<html lang>, viewport ✖ ✖ Hard-coded document shell
<title> ✖ (/) ✖ (the path) No way to declare a title
meta description, canonical, Open Graph, JSON-LD ✖ ✖ No head/metadata concept at all
robots.txt, sitemap.xml ✖ Not served; the page/route list needed for a sitemap is already in the IR

Gaps to decide (each needs an ADR before code)

  1. Page head metadata: title, description, canonical, OG, JSON-LD, derived from query data (for example the article title), with lang on the project. SEO blocker.
  2. Route params (/posts/:slug → page input feeding query inputs), with a sitemap derived from them. Scale blocker.
  3. Data-conditional rendering (ui.match(value, cases) or a ui.if over a guard). Expressiveness blocker (F3).
  4. each over primitive arrays (key by index or by value) and a clearer TN008 cause (F1).
  5. URL construction without fn (typed ui.link(route, params)), which also removes F2's silent-bug class.
  6. HEAD, robots.txt, sitemap.xml in adapter-node / adapter-static.
  7. Holes on cacheable shells: serve the public parts of a mixed page from ISR (F6).
  8. Show TN016's contract skeleton in the human CLI output (F5).

Follow-up (ADR 0008)

Items 1, 2 and 6 are implemented. The blog now uses /posts/:slug with one Article view, head metadata derived from getPost, entries from listPosts, and site. The SEO audit (now 50 checks, stricter) passes 50 / 50: lang, viewport, title, description, canonical, Open Graph, JSON-LD, HEAD, robots.txt with a Sitemap: line, a sitemap expanded from entries, and 404 + noindex derived from getPost's NotFound. Still open: items 3–5, 7 and 8.

Follow-up (ADR 0009 / 0010)