diff --git a/packages/website/public/sitemap.xml b/packages/website/public/sitemap.xml
index 3c8eead..a0e1e74 100644
--- a/packages/website/public/sitemap.xml
+++ b/packages/website/public/sitemap.xml
@@ -32,6 +32,22 @@
+
+ https://tokenizen.nl/blog
+ weekly
+ 0.8
+
+
+
+
+
+ https://tokenizen.nl/en/blog
+ weekly
+ 0.8
+
+
+
+ https://tokenizen.nl/en/notes/check-pay-provemonthly
diff --git a/packages/website/src/App.tsx b/packages/website/src/App.tsx
index 0e4103d..5b8780f 100644
--- a/packages/website/src/App.tsx
+++ b/packages/website/src/App.tsx
@@ -14,7 +14,7 @@ import { NineWaysArticle } from "@/components/pages/nine-ways-article";
import { HierarchyOfAgentTrustArticle } from "@/components/pages/hierarchy-of-agent-trust-article";
import { TrustRadarPage } from "@/components/pages/trust-radar-page";
import { SelfCheckPage } from "@/components/pages/self-check-page";
-import { NotesIndex } from "@/components/pages/notes-index";
+import { BlogIndex, NotesIndex } from "@/components/pages/notes-index";
import { ZeroOf600Article } from "@/components/pages/zero-of-600-article";
import { ReceiptCannotNameArticle } from "@/components/pages/receipt-cannot-name-article";
import { RevocationsArticle } from "@/components/pages/revocations-article";
@@ -27,8 +27,8 @@ import { findPostByPath } from "@/data/blog-posts";
// homepage in plaats van het artikel te tonen.
const ARTICLES: Record JSX.Element> = {
"/notes": NotesIndex,
- // The blog and the notes are one place. /blog is the name people will guess.
- "/blog": NotesIndex,
+ // The blog and the notes are two places. A blog is only what the owner has called one.
+ "/blog": BlogIndex,
"/notes/a-signed-receipt-that-cannot-name-its-own-payment": ReceiptCannotNameArticle,
"/notes/733203-attestations-were-taken-back": RevocationsArticle,
"/notes/zero-of-600-x402-services-sign-a-receipt": ZeroOf600Article,
diff --git a/packages/website/src/components/blog/blog-post-page.test.tsx b/packages/website/src/components/blog/blog-post-page.test.tsx
index 4c00319..e1bfc26 100644
--- a/packages/website/src/components/blog/blog-post-page.test.tsx
+++ b/packages/website/src/components/blog/blog-post-page.test.tsx
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { BlogPostPage } from "@/components/blog/blog-post-page";
-import { NotesIndex } from "@/components/pages/notes-index";
+import { BlogIndex } from "@/components/pages/notes-index";
import { BLOG_POSTS, MISSION } from "@/data/blog-posts";
import { LocaleProvider, type Locale } from "@/i18n/context";
@@ -18,7 +18,7 @@ const renderPost = (locale: Locale) =>
const renderIndex = (locale: Locale) =>
renderToStaticMarkup(
-
+ ,
);
@@ -102,24 +102,27 @@ describe("BlogPostPage", () => {
it("keeps internal links in the reader's language", () => {
expect(renderPost("en")).toContain('href="/en/notes/zero-of-600-x402-services-sign-a-receipt"');
expect(renderPost("nl")).toContain('href="/notes/zero-of-600-x402-services-sign-a-receipt"');
- expect(renderPost("en")).toContain('href="/en/notes"');
- expect(renderPost("nl")).toContain('href="/notes"');
+ // The post is a blog, so its way back leads to the blog and not to the notes.
+ expect(renderPost("en")).toContain('href="/en/blog"');
+ expect(renderPost("nl")).toContain('href="/blog"');
});
});
-describe("NotesIndex as the blog", () => {
- it("features the newest post and shows the mission", () => {
+describe("BlogIndex", () => {
+ it("features the pinned blog and shows no mission", () => {
const html = renderIndex("en");
expect(html).toContain(post.title);
expect(html).toContain("/en/notes/check-pay-prove");
- expect(html).toContain(MISSION.headline);
- expect(html).toContain("Start here");
+ expect(html).not.toContain(MISSION.headline);
+ expect(html).toContain("Pinned");
});
- it("still lists the earlier notes", () => {
+ it("does not list the notes", () => {
const html = renderIndex("en");
- expect(html).toContain("733,203 attestations were taken back");
- expect(html).toContain("The hierarchy of agent trust");
+ expect(html).not.toContain("733,203 attestations were taken back");
+ expect(html).not.toContain("The hierarchy of agent trust");
+ expect(html).not.toContain("Official sources and payment addresses");
+ expect(html).not.toContain("We measured our scan");
});
it("is called Blog in both languages", () => {
diff --git a/packages/website/src/components/blog/blog-post-page.tsx b/packages/website/src/components/blog/blog-post-page.tsx
index 1c1bdbe..3dbeecc 100644
--- a/packages/website/src/components/blog/blog-post-page.tsx
+++ b/packages/website/src/components/blog/blog-post-page.tsx
@@ -173,11 +173,11 @@ export function BlogPostPage({ post }: { post: BlogPost }) {
diff --git a/packages/website/src/components/pages/notes-index.test.tsx b/packages/website/src/components/pages/notes-index.test.tsx
new file mode 100644
index 0000000..257e611
--- /dev/null
+++ b/packages/website/src/components/pages/notes-index.test.tsx
@@ -0,0 +1,75 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import { BlogIndex, NotesIndex, entriesOfKind } from "@/components/pages/notes-index";
+import { BLOG_POSTS, MISSION, postPath } from "@/data/blog-posts";
+import { LocaleProvider, type Locale } from "@/i18n/context";
+
+const render = (locale: Locale, page: "blog" | "notes") =>
+ renderToStaticMarkup({page === "blog" ? : });
+
+const LOCALES: Locale[] = ["nl", "en"];
+
+describe("the blog and the notes are two places", () => {
+ for (const locale of LOCALES) {
+ it(`[${locale}] the blog lists blogs only, with no mission block`, () => {
+ const html = render(locale, "blog");
+ for (const post of BLOG_POSTS) {
+ const listed = html.includes(postPath(post.slug));
+ expect(listed, post.slug).toBe(post.kind === "blog");
+ }
+ expect(html).not.toContain(MISSION.eyebrow);
+ expect(html).not.toContain(MISSION.headline);
+ // The seven older and newer notes must not be in the blog either.
+ expect(html).not.toContain("/notes/733203-attestations-were-taken-back");
+ expect(html).not.toContain("/notes/the-hierarchy-of-agent-trust");
+ });
+
+ it(`[${locale}] the notes list every note and no blog, and no mission block either`, () => {
+ const html = render(locale, "notes");
+ for (const post of BLOG_POSTS) {
+ expect(html.includes(postPath(post.slug)), post.slug).toBe(post.kind === "note");
+ }
+ expect(html).toContain("/notes/733203-attestations-were-taken-back");
+ expect(html).toContain("/notes/nine-ways-to-fake-a-delivery-claim");
+ expect(html).not.toContain(MISSION.eyebrow);
+ });
+ }
+
+ it("shows the pinned blog first, marked as pinned, and keeps it there whatever its date", () => {
+ const blogs = entriesOfKind("blog");
+ expect(blogs[0]?.pinned).toBe(true);
+ expect(blogs[0]?.slug).toBe("/notes/check-pay-prove");
+ const html = render("en", "blog");
+ expect(html).toContain("Pinned");
+ expect(html.indexOf("Check. Pay. Prove.")).toBeGreaterThan(-1);
+ expect(render("nl", "blog")).toContain("Vastgepind");
+ });
+
+ it("sorts a pinned entry above a newer one", () => {
+ const entries = entriesOfKind("blog");
+ const pinnedIndex = entries.findIndex((e) => e.pinned);
+ const newer = entries.findIndex((e) => !e.pinned && e.date > (entries[pinnedIndex]?.date ?? ""));
+ if (newer !== -1) expect(pinnedIndex).toBeLessThan(newer);
+ expect(pinnedIndex).toBe(0);
+ });
+
+ it("points each page at the other", () => {
+ expect(render("en", "blog")).toContain('href="/en/notes"');
+ expect(render("en", "notes")).toContain('href="/en/blog"');
+ expect(render("nl", "blog")).toContain('href="/notes"');
+ expect(render("nl", "notes")).toContain('href="/blog"');
+ });
+
+ it("keeps post urls where they were, so links that are already out there still work", () => {
+ expect(postPath("check-pay-prove")).toBe("/notes/check-pay-prove");
+ expect(postPath("official-sources")).toBe("/notes/official-sources");
+ expect(postPath("we-measured-our-scan")).toBe("/notes/we-measured-our-scan");
+ });
+
+ it("uses no long dashes in either index", () => {
+ for (const page of ["blog", "notes"] as const) {
+ for (const locale of LOCALES) expect(render(locale, page)).not.toMatch(/[–—]/);
+ }
+ });
+});
diff --git a/packages/website/src/components/pages/notes-index.tsx b/packages/website/src/components/pages/notes-index.tsx
index 1d7aadf..4cf1249 100644
--- a/packages/website/src/components/pages/notes-index.tsx
+++ b/packages/website/src/components/pages/notes-index.tsx
@@ -1,28 +1,30 @@
-// notes-index.tsx — the index for /notes, which is also the blog (/blog opens it).
+// notes-index.tsx — the two indexes: /blog lists the blogs, /notes lists the notes.
//
-// The article pages existed before this did, with no index and no link in the
-// navigation, so nothing on the site led to them. Writing something nobody can
-// reach is the same as not writing it.
+// A BLOG IS A DECISION, NOT A DEFAULT. The owner of this site decides what is a blog; everything
+// else we write is a note. Until 2026-10-24 both lived on one page that was called the blog, which
+// is how two working notes of that day ended up in front of readers as blogs. A post is a blog only
+// when its `kind` in data/blog-posts.ts says so, and that line is written when the owner has said
+// "make this a blog", never on our own initiative.
//
-// Two kinds of entry live here. The older notes are one component each and are
-// listed by hand in NOTES below. Newer posts are data in BLOG_POSTS and appear
-// on their own: add the entry there and it shows up in this list, newest first,
-// with the newest as the featured card.
+// Two kinds of note live here. The older ones are one component each and are listed by hand in
+// NOTES below. Newer ones are data in BLOG_POSTS (kind "note") and appear on their own: add the entry
+// there and it shows up, newest first. A pinned post is shown first whatever its date.
//
-// Article copy stays English while this index is bilingual. The articles are
-// written for people implementing against a specification whose own text is
-// English, and a translated copy would mean every correction has to be made
-// twice, which is how one of two versions quietly goes stale. The Dutch index
-// says that in a line rather than leaving a reader to wonder.
+// The blog index carries no mission block. The mission stays at the end of every post, where a reader
+// who landed on one page sees what the work is for; an index should show the posts and nothing else.
+//
+// Article copy stays English while these indexes are bilingual. The articles are written for people
+// implementing against a specification whose own text is English, and a translated copy would mean
+// every correction has to be made twice, which is how one of two versions quietly goes stale. The
+// Dutch index says that in a line rather than leaving a reader to wonder.
import { useEffect } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
-import { MissionBlock } from "@/components/blog/mission-block";
-import { BLOG_POSTS, postPath } from "@/data/blog-posts";
+import { BLOG_POSTS, postPath, type PostKind } from "@/data/blog-posts";
import { useLocale } from "@/i18n/context";
-interface NoteEntry {
+interface Entry {
slug: string;
date: string;
title: string;
@@ -32,7 +34,7 @@ interface NoteEntry {
pinned?: boolean;
}
-const NOTES: NoteEntry[] = [
+const OLDER_NOTES: Entry[] = [
{
slug: "/notes/733203-attestations-were-taken-back",
date: "2026-10-15",
@@ -70,58 +72,87 @@ const NOTES: NoteEntry[] = [
},
];
-const POST_ENTRIES: NoteEntry[] = BLOG_POSTS.map((post) => ({
- slug: postPath(post.slug),
- date: post.date,
- title: post.title,
- blurb: post.dek,
- tags: post.tags,
- readMinutes: post.readMinutes,
- pinned: post.pinned,
-}));
-
-// A pinned post first, then newest first. On the same date the data-driven posts
-// come first, because they are the ones written after the hand-listed notes.
-const ENTRIES: NoteEntry[] = [...POST_ENTRIES, ...NOTES].sort(
- (a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)) || b.date.localeCompare(a.date),
-);
+/** The entries of one kind: pinned first, then newest first. On the same date the data posts come first. */
+export function entriesOfKind(kind: PostKind): Entry[] {
+ const fromData: Entry[] = BLOG_POSTS.filter((post) => post.kind === kind).map((post) => ({
+ slug: postPath(post.slug),
+ date: post.date,
+ title: post.title,
+ blurb: post.dek,
+ tags: post.tags,
+ readMinutes: post.readMinutes,
+ pinned: post.pinned,
+ }));
+ const all = kind === "note" ? [...fromData, ...OLDER_NOTES] : fromData;
+ return all.sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)) || b.date.localeCompare(a.date));
+}
const COPY = {
en: {
- eyebrow: "Blog",
- heading: "Blog",
- intro:
- "Measurements, tools and working notes for engineers who build with AI agents that pay. Where there is a number, the commands that produced it are in the text, so you can get it yourself and disagree with us on the facts.",
+ blog: {
+ heading: "Blog",
+ intro:
+ "Longer pieces with a story and a point of view. The measurements, tools and working notes are kept apart, under Notes.",
+ other: "Notes",
+ otherHint: "Looking for the measurements and working notes?",
+ otherHref: "/notes",
+ latest: "Start here",
+ more: "More blogs",
+ empty: "Nothing else here yet.",
+ },
+ note: {
+ heading: "Notes",
+ intro:
+ "Measurements, tools and working notes for engineers who build with AI agents that pay. Where there is a number, the commands that produced it are in the text, so you can get it yourself and disagree with us on the facts.",
+ other: "Blog",
+ otherHint: "Looking for the longer pieces?",
+ otherHref: "/blog",
+ latest: "Newest",
+ more: "Earlier notes",
+ empty: "Nothing here yet.",
+ },
languageNote: null as string | null,
back: "Back to the main page",
readMore: "Read",
- latest: "Start here",
- earlier: "Earlier notes",
+ pinned: "Pinned",
minutes: "min read",
},
nl: {
- eyebrow: "Blog",
- heading: "Blog",
- intro:
- "Metingen, tools en werknotities voor engineers die bouwen met AI-agents die betalen. Staat er een getal, dan staan de commando's die het opleverden in de tekst, zodat je het zelf kunt natrekken en het op de feiten met ons oneens kunt zijn.",
+ blog: {
+ heading: "Blog",
+ intro:
+ "Langere stukken met een verhaal en een standpunt. De metingen, tools en werknotities staan apart, onder Notities.",
+ other: "Notities",
+ otherHint: "Zoek je de metingen en werknotities?",
+ otherHref: "/notes",
+ latest: "Begin hier",
+ more: "Meer blogs",
+ empty: "Hier staat verder nog niets.",
+ },
+ note: {
+ heading: "Notities",
+ intro:
+ "Metingen, tools en werknotities voor engineers die bouwen met AI-agents die betalen. Staat er een getal, dan staan de commando's die het opleverden in de tekst, zodat je het zelf kunt natrekken en het op de feiten met ons oneens kunt zijn.",
+ other: "Blog",
+ otherHint: "Zoek je de langere stukken?",
+ otherHref: "/blog",
+ latest: "Nieuwste",
+ more: "Eerdere notities",
+ empty: "Hier staat nog niets.",
+ },
languageNote:
- "De artikelen zelf staan in het Engels. Ze zijn geschreven voor mensen die tegen een specificatie aan bouwen die zelf Engels is, en een tweede versie zou betekenen dat elke correctie twee keer moet.",
+ "De artikelen zelf staan in het Engels. Ze zijn geschreven voor mensen die tegen een specificatie aan bouwen die zelf Engels is, en een tweede versie zou betekenen dat elke correctie twee keer moet gebeuren.",
back: "Terug naar de hoofdpagina",
readMore: "Lezen",
- latest: "Begin hier",
- earlier: "Eerdere notities",
+ pinned: "Vastgepind",
minutes: "min lezen",
},
} as const;
-const TITLE_BY_LOCALE = {
- en: "Blog | tokenizen",
- nl: "Blog | tokenizen",
-} as const;
-
-export function NotesIndex() {
+function PostIndex({ kind }: { kind: PostKind }) {
const { locale } = useLocale();
- const copy = COPY[locale as keyof typeof COPY] ?? COPY.en;
+ const lang = COPY[locale as keyof typeof COPY] ?? COPY.en;
+ const copy = lang[kind === "blog" ? "blog" : "note"];
// The English site lives under /en, and App.tsx strips that prefix before it
// looks a slug up. A bare href would therefore drop an English reader onto
// the Dutch page without saying so.
@@ -129,13 +160,13 @@ export function NotesIndex() {
useEffect(() => {
const previous = document.title;
- document.title = TITLE_BY_LOCALE[locale as keyof typeof TITLE_BY_LOCALE] ?? TITLE_BY_LOCALE.en;
+ document.title = `${copy.heading} | tokenizen`;
return () => {
document.title = previous;
};
- }, [locale]);
+ }, [copy.heading]);
- const [featured, ...rest] = ENTRIES;
+ const [featured, ...rest] = entriesOfKind(kind);
return (
@@ -145,27 +176,29 @@ export function NotesIndex() {
className="inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
- {copy.back}
+ {lang.back}
-
);
}
+
+/** /blog: only what the owner has called a blog, the pinned one first. */
+export function BlogIndex() {
+ return ;
+}
+
+/** /notes: the measurements and working notes. */
+export function NotesIndex() {
+ return ;
+}
diff --git a/packages/website/src/data/blog-posts.test.ts b/packages/website/src/data/blog-posts.test.ts
index cab9188..0c703e1 100644
--- a/packages/website/src/data/blog-posts.test.ts
+++ b/packages/website/src/data/blog-posts.test.ts
@@ -99,6 +99,15 @@ describe("BLOG_POSTS", () => {
}
});
+ // A blog is a decision of the owner, not a default. The default for new work is a note, and only a
+ // blog may be pinned, so a working note can never end up at the top of the blog by accident.
+ it("gives every post a kind, and lets only a blog be pinned", () => {
+ for (const post of BLOG_POSTS) {
+ expect(["blog", "note"], post.slug).toContain(post.kind);
+ if (post.pinned) expect(post.kind, post.slug).toBe("blog");
+ }
+ });
+
it("finds a post by its path and nothing else", () => {
const first = BLOG_POSTS[0]!;
expect(findPostByPath(postPath(first.slug))).toBe(first);
diff --git a/packages/website/src/data/blog-posts.ts b/packages/website/src/data/blog-posts.ts
index ebef6b1..2bb7b44 100644
--- a/packages/website/src/data/blog-posts.ts
+++ b/packages/website/src/data/blog-posts.ts
@@ -31,6 +31,13 @@ export type PostBlock =
| { type: "mission" }
| { type: "cta"; heading: string; text: string; links: Array<{ label: string; href: string }> };
+/**
+ * A blog is a decision, not a default. Only a piece the owner has called a blog gets kind "blog"; everything
+ * else is a note, and the default for new work is "note". Changing a post to "blog" is done when the owner
+ * says "make this a blog", never on our own initiative.
+ */
+export type PostKind = "blog" | "note";
+
export interface BlogPost {
/** The part after /notes/ . Lowercase words and dashes. */
slug: string;
@@ -45,6 +52,8 @@ export interface BlogPost {
readMinutes: number;
/** Shown first on the index, whatever its date. For the post a newcomer should read first. */
pinned?: boolean;
+ /** Which index lists it: /blog for "blog", /notes for "note". */
+ kind: PostKind;
blocks: PostBlock[];
}
@@ -70,6 +79,7 @@ export const BLOG_POSTS: BlogPost[] = [
tags: ["Tools", "Measurements"],
readMinutes: 4,
pinned: true,
+ kind: "blog",
blocks: [
{
type: "md",
@@ -199,6 +209,7 @@ Money is starting to move between machines. The people who build those machines
},
{
slug: "official-sources",
+ kind: "note",
date: "2026-10-24",
title: "Official sources and payment addresses",
dek: "Where our paid service lives and where its payments go, so a buyer or an agent can check an address before paying.",
@@ -238,6 +249,7 @@ It also says nothing about how good the scan is. The scan reads the files most l
},
{
slug: "we-measured-our-scan",
+ kind: "note",
date: "2026-10-24",
title: "We measured our scan. It found 0 of 40.",
dek: "What we measured on closed audit contests, what we changed, and how the next test is sealed so that we cannot bend it.",
diff --git a/packages/website/src/i18n/en.ts b/packages/website/src/i18n/en.ts
index 2a3492d..4064176 100644
--- a/packages/website/src/i18n/en.ts
+++ b/packages/website/src/i18n/en.ts
@@ -48,8 +48,12 @@ export const en: Dictionary = {
"label": "Status"
},
{
- "href": "/notes",
+ "href": "/blog",
"label": "Blog"
+ },
+ {
+ "href": "/notes",
+ "label": "Notes"
}
],
"github": "GitHub",
diff --git a/packages/website/src/i18n/nl.ts b/packages/website/src/i18n/nl.ts
index bd57e81..56981b8 100644
--- a/packages/website/src/i18n/nl.ts
+++ b/packages/website/src/i18n/nl.ts
@@ -26,7 +26,8 @@ export const nl: Dictionary = {
{ href: "#ontwerpgrenzen", label: "Grenzen" },
{ href: "#open-source", label: "Open source" },
{ href: "#status", label: "Status" },
- { href: "/notes", label: "Blog" },
+ { href: "/blog", label: "Blog" },
+ { href: "/notes", label: "Notities" },
],
github: "GitHub",
openMenu: "Open menu",