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)
- Page head metadata:
title,description,canonical, OG, JSON-LD, derived from query data (for example the article title), withlangon the project. SEO blocker. - Route params (
/posts/:slug→ page input feeding query inputs), with a sitemap derived from them. Scale blocker. - Data-conditional rendering (
ui.match(value, cases)or aui.ifover a guard). Expressiveness blocker (F3). eachover primitive arrays (key by index or by value) and a clearer TN008 cause (F1).- URL construction without
fn(typedui.link(route, params)), which also removes F2's silent-bug class. HEAD,robots.txt,sitemap.xmlinadapter-node/adapter-static.- Holes on cacheable shells: serve the public parts of a mixed page from ISR (F6).
- 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)
- Item 3:
ui.if(guard, then, else)renders on data (G1). - Item 4:
ui.each(list, null, item)iterates primitives keyed by value (G2). - Item 5:
ui.link(route, params)builds typed URLs. The blog uses it, so F2's silent wrong-link bug class is gone. - The blog is styled with Tailwind + the typography plugin; TN026 caught the original
proseclass that produced no CSS. - Still open: item 7 (ISR holes in mixed pages), item 8 (TN016 snippet in the human CLI output), and F3's "is this
post saved?". The last one needs a membership guard (
op.includes), which is not implemented yet.