Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions website/blog/merkle_ai_batching.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { css } from "../styled-system/css";
import MermaidDiagram from "../components/MermaidDiagram";
import { Link } from "../components/Link";

// Simplified LLM Prepaid Workflow with Merkle Batching
const UPDATED_LLM_WORKFLOW_DEFINITION = `sequenceDiagram
Expand Down Expand Up @@ -53,7 +54,7 @@ export default function MerkleAIBatching() {

<p>
<strong>My initial answer is now live:</strong>{" "}
<a
<Link
href="/assistent"
className={css({
fontWeight: "bold",
Expand All @@ -62,7 +63,7 @@ export default function MerkleAIBatching() {
})}
>
Feel free to try my AI assistant
</a>{" "}
</Link>{" "}
- you connect your wallet, deposit ETH, and chat with an LLM. No subscriptions, no accounts, no data weird
harvesting. And you just pay for exactly what you use.
</p>
Expand Down Expand Up @@ -113,7 +114,7 @@ export default function MerkleAIBatching() {
<h2>Building on Previous Blockchain-AI Experience</h2>

<p>
I previously built an <a href="/blog/9">AI image generator with blockchain payments</a>, proving that
I previously built an <Link href="/blog/9">AI image generator with blockchain payments</Link>, proving that
crypto-native AI services can work. But LLMs present fundamentally different challenges: users expect multiple
requests per session, instant responses, and variable costs per interaction. The traditional &quot;one
transaction per request&quot; model becomes prohibitively expensive and cumbersome.
Expand All @@ -133,8 +134,8 @@ export default function MerkleAIBatching() {
After evaluating different approaches, Merkle tree batching emerged as the optimal solution - providing
cryptographic proof of each interaction while enabling efficient batch processing. This approach balances the
competing demands of user experience, cost efficiency, and technical simplicity. I explored this technique in
detail in my <a href="/blog/15">previous post on Merkle tree fundamentals</a>. While that post covered the
mathematical foundations, here we&apos;ll focus on the practical implementation for real-time AI services.
detail in my <Link href="/blog/15">previous post on Merkle tree fundamentals</Link>. While that post covered
the mathematical foundations, here we&apos;ll focus on the practical implementation for real-time AI services.
</p>

<p>
Expand Down
3 changes: 2 additions & 1 deletion website/blog/merkle_ai_batching_fundamentals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { css } from "../styled-system/css";
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
import MermaidDiagram from "../components/MermaidDiagram";
import { Link } from "../components/Link";

// Mermaid diagram definitions
const MERKLE_TREE_MATH_DEFINITION = `graph TD
Expand Down Expand Up @@ -1094,7 +1095,7 @@ export default function MerkleAIBatching() {

<p>
I&apos;ve already built an AI image generator that works on the blockchain - you can try it on my{" "}
<a href="/imagegen">image generation page</a>. Users pay with their wallet, and my{" "}
<Link href="/imagegen">image generation page</Link>. Users pay with their wallet, and my{" "}
<a href="https://optimistic.etherscan.io/address/0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb#code">
GenImNFT contract
</a>{" "}
Expand Down
4 changes: 2 additions & 2 deletions website/blog/x402_facilitator_imagegen.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ sequenceDiagram

## Introduction

Over the last year, I've built two AI services with blockchain payments: an [image generator with NFT minting](/blog/9) and an [LLM assistant with Merkle-tree batching](/blog/16). Both work well – users can pay anonymously with crypto, no accounts needed, costs under control.
Over the last year, I've built two AI services with blockchain payments: an [image generator with NFT minting](/blog/9/) and an [LLM assistant with Merkle-tree batching](/blog/16/). Both work well – users can pay anonymously with crypto, no accounts needed, costs under control.

But each service has its own payment logic. My image generator uses a custom smart contract flow, my LLM assistant uses prepaid deposits. If someone wanted to build a client that uses both, they'd need to understand two completely different payment systems. And if an AI agent wanted to pay for my services autonomously? It would need custom integration for each endpoint.

Expand Down Expand Up @@ -178,7 +178,7 @@ Now that we understand the infrastructure, let's see how the actual image genera

## The ImageGen Endpoint

Part of my website is the imagegen feature, which calls a serverless function to generate AI images via Black Forest Labs and mints an NFT via the GenImNFTv4 contract. The NFT serves as a certificate of authenticity and proof of ownership for the generated image – I've written more about the [original implementation](/blog/9) and the [gallery features](/blog/12) in previous posts. This endpoint is now upgraded to use the x402 standard for payments and accessible to anyone interested under [imagegen-agent.fretchen.eu](https://imagegen-agent.fretchen.eu). How does the endpoint work now?
Part of my website is the imagegen feature, which calls a serverless function to generate AI images via Black Forest Labs and mints an NFT via the GenImNFTv4 contract. The NFT serves as a certificate of authenticity and proof of ownership for the generated image – I've written more about the [original implementation](/blog/9/) and the [gallery features](/blog/12/) in previous posts. This endpoint is now upgraded to use the x402 standard for payments and accessible to anyone interested under [imagegen-agent.fretchen.eu](https://imagegen-agent.fretchen.eu). How does the endpoint work now?

Using the official x402 TypeScript SDK, the buyer-side code is remarkably simple:

Expand Down
8 changes: 5 additions & 3 deletions website/components/EntryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import { SITE } from "../utils/siteData";
*/
const EntryList: React.FC<EntryListProps> = ({
blogs,
basePath,
basePath: rawBasePath,
titleClassName,
showDate = false,
reverseOrder = false,
limit,
showViewAllLink = false,
}) => {
// Normalize basePath: strip trailing slash to prevent double-slash URLs
const basePath = rawBasePath.endsWith("/") ? rawBasePath.slice(0, -1) : rawBasePath;
let displayBlogs = reverseOrder ? [...blogs].reverse() : blogs;
const hasMore = limit && blogs.length > limit;
if (limit) {
Expand All @@ -39,7 +41,7 @@ const EntryList: React.FC<EntryListProps> = ({
// This ensures correct link indices when the blog list is filtered by category
const linkIndex =
blog.originalIndex !== undefined ? blog.originalIndex : reverseOrder ? blogs.length - 1 - index : index;
const entryUrl = `${basePath}/${linkIndex}`;
const entryUrl = `${basePath}/${linkIndex}/`;

// Format publishing date as ISO8601 for dt-published if available
const isoDatetime = blog.publishing_date ? new Date(blog.publishing_date).toISOString().split("T")[0] : null;
Expand Down Expand Up @@ -114,7 +116,7 @@ const EntryList: React.FC<EntryListProps> = ({
{/* View All Link */}
{hasMore && showViewAllLink && (
<div className={entryList.viewAllContainer}>
<Link href={basePath}>View all entries →</Link>
<Link href={`${basePath}/`}>View all entries →</Link>
</div>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions website/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export function Link({
locale = defaultLocale;
}

// Ensure trailing slash for internal page links (not files, hashes, or queries)
if (!href.endsWith("/") && !href.includes(".") && !href.includes("#") && !href.includes("?")) {
href += "/";
}

// Only add locale prefix for non-default locale
if (locale !== defaultLocale) {
href = "/" + locale + href;
Expand Down
5 changes: 3 additions & 2 deletions website/components/MetadataLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ export default function MetadataLine({
const { supportCount, isLoading, isSuccess, errorMessage, isConnected, handleSupport, isReadPending, readError } =
useSupportAction(showSupport ? currentUrl : "");

// Format publishing date
// Format publishing date — parse as local time to avoid timezone shifts
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
const [year, month, day] = dateString.split("-").map(Number);
const date = new Date(year, month - 1, day);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
Expand Down
9 changes: 5 additions & 4 deletions website/pages/x402/+Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import MermaidDiagram from "../../components/MermaidDiagram";
import { FacilitatorApproval } from "../../components/FacilitatorApproval";
import { titleBar } from "../../layouts/styles";
import * as styles from "../../layouts/styles";
import { Link } from "../../components/Link";

// ─── Mermaid diagram definitions ─────────────────────────────────────────────

Expand Down Expand Up @@ -389,7 +390,7 @@ return new Response(JSON.stringify(result), { status: 200 });`}</code>
</pre>
<p>
That&apos;s it — your service now accepts crypto payments. See the{" "}
<a href="/agent-onboarding">agent onboarding guide</a> for a complete walkthrough.
<Link href="/agent-onboarding">agent onboarding guide</Link> for a complete walkthrough.
</p>
</div>

Expand Down Expand Up @@ -728,7 +729,7 @@ app.post("/api/resource", async (req, res) => {
<p>
Each payment is individually signed via <a href="https://eips.ethereum.org/EIPS/eip-3009">EIP-3009</a>. The
authorization is bound to a specific amount, recipient, and expiration. The protocol never has blanket access
to your customer&apos;s funds. See the <a href="/imagegen">AI Image Generator</a> for a live example.
to your customer&apos;s funds. See the <Link href="/imagegen">AI Image Generator</Link> for a live example.
</p>

{/* ── 9. Links ─────────────────────────────────────────────────── */}
Expand All @@ -747,10 +748,10 @@ app.post("/api/resource", async (req, res) => {
</a>
</li>
<li>
<a href="/imagegen">AI Image Generator</a> — live x402 service using this facilitator
<Link href="/imagegen">AI Image Generator</Link> — live x402 service using this facilitator
</li>
<li>
<a href="/agent-onboarding">Agent onboarding</a> — build your own x402-protected service
<Link href="/agent-onboarding">Agent onboarding</Link> — build your own x402-protected service
</li>
</ul>
</div>
Expand Down
1 change: 1 addition & 0 deletions website/public/CNAME
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
www.fretchen.eu
30 changes: 15 additions & 15 deletions website/test/EntryList.filtering.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ describe("EntryList - Link consistency after filtering", () => {
const links = screen.getAllByRole("link");

// Links should use originalIndex, not array position
expect(links[0]).toHaveAttribute("href", "/blog/5");
expect(links[1]).toHaveAttribute("href", "/blog/12");
expect(links[2]).toHaveAttribute("href", "/blog/18");
expect(links[0]).toHaveAttribute("href", "/blog/5/");
expect(links[1]).toHaveAttribute("href", "/blog/12/");
expect(links[2]).toHaveAttribute("href", "/blog/18/");
});

/**
Expand All @@ -77,9 +77,9 @@ describe("EntryList - Link consistency after filtering", () => {
const links = screen.getAllByRole("link");

// Without originalIndex, use array position
expect(links[0]).toHaveAttribute("href", "/blog/0");
expect(links[1]).toHaveAttribute("href", "/blog/1");
expect(links[2]).toHaveAttribute("href", "/blog/2");
expect(links[0]).toHaveAttribute("href", "/blog/0/");
expect(links[1]).toHaveAttribute("href", "/blog/1/");
expect(links[2]).toHaveAttribute("href", "/blog/2/");
});

/**
Expand All @@ -98,11 +98,11 @@ describe("EntryList - Link consistency after filtering", () => {

// With reverseOrder, display is reversed but originalIndex stays the same
// Post C (originalIndex 11) should be first in display
expect(links[0]).toHaveAttribute("href", "/blog/11");
expect(links[0]).toHaveAttribute("href", "/blog/11/");
// Post B (originalIndex 7) should be second
expect(links[1]).toHaveAttribute("href", "/blog/7");
expect(links[1]).toHaveAttribute("href", "/blog/7/");
// Post A (originalIndex 3) should be third
expect(links[2]).toHaveAttribute("href", "/blog/3");
expect(links[2]).toHaveAttribute("href", "/blog/3/");
});

/**
Expand All @@ -120,11 +120,11 @@ describe("EntryList - Link consistency after filtering", () => {
const links = screen.getAllByRole("link");

// First blog: use originalIndex
expect(links[0]).toHaveAttribute("href", "/blog/10");
expect(links[0]).toHaveAttribute("href", "/blog/10/");
// Second blog: fall back to array index (1)
expect(links[1]).toHaveAttribute("href", "/blog/1");
expect(links[1]).toHaveAttribute("href", "/blog/1/");
// Third blog: use originalIndex
expect(links[2]).toHaveAttribute("href", "/blog/20");
expect(links[2]).toHaveAttribute("href", "/blog/20/");
});

/**
Expand Down Expand Up @@ -161,8 +161,8 @@ describe("EntryList - Link consistency after filtering", () => {
const links = screen.getAllByRole("link");

// Links should point to original indices, not filtered positions
expect(links[0]).toHaveAttribute("href", "/blog/1"); // AI Post 1 was at index 1
expect(links[1]).toHaveAttribute("href", "/blog/3"); // AI Post 2 was at index 3
expect(links[2]).toHaveAttribute("href", "/blog/5"); // AI Post 3 was at index 5
expect(links[0]).toHaveAttribute("href", "/blog/1/"); // AI Post 1 was at index 1
expect(links[1]).toHaveAttribute("href", "/blog/3/"); // AI Post 2 was at index 3
expect(links[2]).toHaveAttribute("href", "/blog/5/"); // AI Post 3 was at index 5
});
});
29 changes: 21 additions & 8 deletions website/test/EntryList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ describe("EntryList Component", () => {
const links = screen.getAllByRole("link");

// Bei reverseOrder sollte der erste Link zum letzten Blog führen (Index 2)
expect(links[0]).toHaveAttribute("href", "/blog/2");
expect(links[0]).toHaveAttribute("href", "/blog/2/");
// Der dritte Link sollte zum ersten Blog führen (Index 0)
expect(links[2]).toHaveAttribute("href", "/blog/0");
expect(links[2]).toHaveAttribute("href", "/blog/0/");
});

/**
Expand All @@ -132,7 +132,7 @@ describe("EntryList Component", () => {

const viewAllLink = screen.getByText("View all entries →");
expect(viewAllLink).toBeInTheDocument();
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/blog");
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/blog/");
});

/**
Expand All @@ -146,9 +146,9 @@ describe("EntryList Component", () => {
// Jetzt ist die ganze Card klickbar, daher suchen wir nach allen Links
const links = screen.getAllByRole("link");

expect(links[0]).toHaveAttribute("href", "/blog/0");
expect(links[1]).toHaveAttribute("href", "/blog/1");
expect(links[2]).toHaveAttribute("href", "/blog/2");
expect(links[0]).toHaveAttribute("href", "/blog/0/");
expect(links[1]).toHaveAttribute("href", "/blog/1/");
expect(links[2]).toHaveAttribute("href", "/blog/2/");
});

/**
Expand Down Expand Up @@ -187,11 +187,24 @@ describe("EntryList Component", () => {

// Jetzt ist die ganze Card klickbar, daher suchen wir nach allen Links
const links = screen.getAllByRole("link");
expect(links[0]).toHaveAttribute("href", "/quantum/basics/0");
expect(links[0]).toHaveAttribute("href", "/quantum/basics/0/");

const viewAllLink = screen.queryByText("View all entries →");
if (viewAllLink) {
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/quantum/basics");
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/quantum/basics/");
}
});

it("normalizes basePath with trailing slash to avoid double slashes", () => {
render(<EntryList {...defaultProps} basePath="/blog/" limit={2} showViewAllLink={true} />);

const links = screen.getAllByRole("link");
// Entry links should not have double slashes
expect(links[0]).toHaveAttribute("href", "/blog/0/");
expect(links[1]).toHaveAttribute("href", "/blog/1/");

// View all link should also be clean
const viewAllLink = screen.getByText("View all entries →");
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/blog/");
});
});
34 changes: 18 additions & 16 deletions website/test/LanguageToggle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ vi.mock("vike-react/usePageContext", () => ({

vi.mock("../locales/extractLocale", () => ({
extractLocale: (pathname: string) => {
// Simulate the extractLocale logic for testing
if (pathname.startsWith("/de/")) {
// Simulate the extractLocale logic including trailing slash normalization
const addTrailingSlash = (p: string) => (p === "/" || p.endsWith("/") ? p : p + "/");
if (pathname.startsWith("/de/") || pathname === "/de") {
const withoutLocale = pathname.replace(/^\/de/, "") || "/";
return {
locale: "de",
urlPathnameWithoutLocale: pathname.replace("/de", "") || "/",
urlPathnameWithoutLocale: addTrailingSlash(withoutLocale),
};
}
return {
locale: "en",
urlPathnameWithoutLocale: pathname,
urlPathnameWithoutLocale: addTrailingSlash(pathname),
};
},
}));
Expand All @@ -52,10 +54,10 @@ describe("LanguageToggle URL Generation", () => {
const deLink = screen.getByLabelText("Switch to German");

// English should use root path (matches build structure)
expect(enLink).toHaveAttribute("href", "/imagegen");
expect(enLink).toHaveAttribute("href", "/imagegen/");

// German should use prefixed path (matches build structure)
expect(deLink).toHaveAttribute("href", "/de/imagegen");
expect(deLink).toHaveAttribute("href", "/de/imagegen/");
});

it("should generate correct URLs for root page", () => {
Expand Down Expand Up @@ -84,10 +86,10 @@ describe("LanguageToggle URL Generation", () => {
const deLink = screen.getByLabelText("Switch to German");

// Switch from German to English: remove /de/ prefix
expect(enLink).toHaveAttribute("href", "/assistent");
expect(enLink).toHaveAttribute("href", "/assistent/");

// Stay on German: keep /de/ prefix
expect(deLink).toHaveAttribute("href", "/de/assistent");
expect(deLink).toHaveAttribute("href", "/de/assistent/");
});

it("should handle deep nested paths correctly", () => {
Expand All @@ -101,10 +103,10 @@ describe("LanguageToggle URL Generation", () => {
const deLink = screen.getByLabelText("Switch to German");

// English: root path (no prefix)
expect(enLink).toHaveAttribute("href", "/blog/5");
expect(enLink).toHaveAttribute("href", "/blog/5/");

// German: prefixed path
expect(deLink).toHaveAttribute("href", "/de/blog/5");
expect(deLink).toHaveAttribute("href", "/de/blog/5/");
});
});

Expand Down Expand Up @@ -148,18 +150,18 @@ describe("LanguageToggle URL Generation", () => {
const testCases = [
{
current: "/imagegen",
expectedEn: "/imagegen", // Matches build/client/imagegen/index.html
expectedDe: "/de/imagegen", // Matches build/client/de/imagegen/index.html
expectedEn: "/imagegen/", // Matches build/client/imagegen/index.html
expectedDe: "/de/imagegen/", // Matches build/client/de/imagegen/index.html
},
{
current: "/assistent",
expectedEn: "/assistent", // Matches build/client/assistent/index.html
expectedDe: "/de/assistent", // Matches build/client/de/assistent/index.html
expectedEn: "/assistent/", // Matches build/client/assistent/index.html
expectedDe: "/de/assistent/", // Matches build/client/de/assistent/index.html
},
{
current: "/blog",
expectedEn: "/blog", // Matches build/client/blog/index.html
expectedDe: "/de/blog", // Matches build/client/de/blog/index.html
expectedEn: "/blog/", // Matches build/client/blog/index.html
expectedDe: "/de/blog/", // Matches build/client/de/blog/index.html
},
];

Expand Down
Loading
Loading