Skip to content

Commit 047ee40

Browse files
authored
More consistent trailing slashes (#350)
1 parent 558e4d4 commit 047ee40

13 files changed

Lines changed: 217 additions & 56 deletions

website/blog/merkle_ai_batching.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import { css } from "../styled-system/css";
33
import MermaidDiagram from "../components/MermaidDiagram";
4+
import { Link } from "../components/Link";
45

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

5455
<p>
5556
<strong>My initial answer is now live:</strong>{" "}
56-
<a
57+
<Link
5758
href="/assistent"
5859
className={css({
5960
fontWeight: "bold",
@@ -62,7 +63,7 @@ export default function MerkleAIBatching() {
6263
})}
6364
>
6465
Feel free to try my AI assistant
65-
</a>{" "}
66+
</Link>{" "}
6667
- you connect your wallet, deposit ETH, and chat with an LLM. No subscriptions, no accounts, no data weird
6768
harvesting. And you just pay for exactly what you use.
6869
</p>
@@ -113,7 +114,7 @@ export default function MerkleAIBatching() {
113114
<h2>Building on Previous Blockchain-AI Experience</h2>
114115

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

140141
<p>

website/blog/merkle_ai_batching_fundamentals.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
22
import { css } from "../styled-system/css";
33
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
44
import MermaidDiagram from "../components/MermaidDiagram";
5+
import { Link } from "../components/Link";
56

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

10951096
<p>
10961097
I&apos;ve already built an AI image generator that works on the blockchain - you can try it on my{" "}
1097-
<a href="/imagegen">image generation page</a>. Users pay with their wallet, and my{" "}
1098+
<Link href="/imagegen">image generation page</Link>. Users pay with their wallet, and my{" "}
10981099
<a href="https://optimistic.etherscan.io/address/0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb#code">
10991100
GenImNFT contract
11001101
</a>{" "}

website/blog/x402_facilitator_imagegen.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ sequenceDiagram
3838

3939
## Introduction
4040

41-
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.
41+
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.
4242

4343
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.
4444

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

179179
## The ImageGen Endpoint
180180

181-
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?
181+
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?
182182

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

website/components/EntryList.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import { SITE } from "../utils/siteData";
1919
*/
2020
const EntryList: React.FC<EntryListProps> = ({
2121
blogs,
22-
basePath,
22+
basePath: rawBasePath,
2323
titleClassName,
2424
showDate = false,
2525
reverseOrder = false,
2626
limit,
2727
showViewAllLink = false,
2828
}) => {
29+
// Normalize basePath: strip trailing slash to prevent double-slash URLs
30+
const basePath = rawBasePath.endsWith("/") ? rawBasePath.slice(0, -1) : rawBasePath;
2931
let displayBlogs = reverseOrder ? [...blogs].reverse() : blogs;
3032
const hasMore = limit && blogs.length > limit;
3133
if (limit) {
@@ -39,7 +41,7 @@ const EntryList: React.FC<EntryListProps> = ({
3941
// This ensures correct link indices when the blog list is filtered by category
4042
const linkIndex =
4143
blog.originalIndex !== undefined ? blog.originalIndex : reverseOrder ? blogs.length - 1 - index : index;
42-
const entryUrl = `${basePath}/${linkIndex}`;
44+
const entryUrl = `${basePath}/${linkIndex}/`;
4345

4446
// Format publishing date as ISO8601 for dt-published if available
4547
const isoDatetime = blog.publishing_date ? new Date(blog.publishing_date).toISOString().split("T")[0] : null;
@@ -114,7 +116,7 @@ const EntryList: React.FC<EntryListProps> = ({
114116
{/* View All Link */}
115117
{hasMore && showViewAllLink && (
116118
<div className={entryList.viewAllContainer}>
117-
<Link href={basePath}>View all entries →</Link>
119+
<Link href={`${basePath}/`}>View all entries →</Link>
118120
</div>
119121
)}
120122
</div>

website/components/Link.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export function Link({
2525
locale = defaultLocale;
2626
}
2727

28+
// Ensure trailing slash for internal page links (not files, hashes, or queries)
29+
if (!href.endsWith("/") && !href.includes(".") && !href.includes("#") && !href.includes("?")) {
30+
href += "/";
31+
}
32+
2833
// Only add locale prefix for non-default locale
2934
if (locale !== defaultLocale) {
3035
href = "/" + locale + href;

website/components/MetadataLine.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ export default function MetadataLine({
3535
const { supportCount, isLoading, isSuccess, errorMessage, isConnected, handleSupport, isReadPending, readError } =
3636
useSupportAction(showSupport ? currentUrl : "");
3737

38-
// Format publishing date
38+
// Format publishing date — parse as local time to avoid timezone shifts
3939
const formatDate = (dateString: string) => {
4040
try {
41-
const date = new Date(dateString);
41+
const [year, month, day] = dateString.split("-").map(Number);
42+
const date = new Date(year, month - 1, day);
4243
return date.toLocaleDateString("en-US", {
4344
year: "numeric",
4445
month: "long",

website/pages/x402/+Page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import MermaidDiagram from "../../components/MermaidDiagram";
44
import { FacilitatorApproval } from "../../components/FacilitatorApproval";
55
import { titleBar } from "../../layouts/styles";
66
import * as styles from "../../layouts/styles";
7+
import { Link } from "../../components/Link";
78

89
// ─── Mermaid diagram definitions ─────────────────────────────────────────────
910

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

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

734735
{/* ── 9. Links ─────────────────────────────────────────────────── */}
@@ -747,10 +748,10 @@ app.post("/api/resource", async (req, res) => {
747748
</a>
748749
</li>
749750
<li>
750-
<a href="/imagegen">AI Image Generator</a> — live x402 service using this facilitator
751+
<Link href="/imagegen">AI Image Generator</Link> — live x402 service using this facilitator
751752
</li>
752753
<li>
753-
<a href="/agent-onboarding">Agent onboarding</a> — build your own x402-protected service
754+
<Link href="/agent-onboarding">Agent onboarding</Link> — build your own x402-protected service
754755
</li>
755756
</ul>
756757
</div>

website/public/CNAME

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
www.fretchen.eu

website/test/EntryList.filtering.test.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ describe("EntryList - Link consistency after filtering", () => {
5858
const links = screen.getAllByRole("link");
5959

6060
// Links should use originalIndex, not array position
61-
expect(links[0]).toHaveAttribute("href", "/blog/5");
62-
expect(links[1]).toHaveAttribute("href", "/blog/12");
63-
expect(links[2]).toHaveAttribute("href", "/blog/18");
61+
expect(links[0]).toHaveAttribute("href", "/blog/5/");
62+
expect(links[1]).toHaveAttribute("href", "/blog/12/");
63+
expect(links[2]).toHaveAttribute("href", "/blog/18/");
6464
});
6565

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

7979
// Without originalIndex, use array position
80-
expect(links[0]).toHaveAttribute("href", "/blog/0");
81-
expect(links[1]).toHaveAttribute("href", "/blog/1");
82-
expect(links[2]).toHaveAttribute("href", "/blog/2");
80+
expect(links[0]).toHaveAttribute("href", "/blog/0/");
81+
expect(links[1]).toHaveAttribute("href", "/blog/1/");
82+
expect(links[2]).toHaveAttribute("href", "/blog/2/");
8383
});
8484

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

9999
// With reverseOrder, display is reversed but originalIndex stays the same
100100
// Post C (originalIndex 11) should be first in display
101-
expect(links[0]).toHaveAttribute("href", "/blog/11");
101+
expect(links[0]).toHaveAttribute("href", "/blog/11/");
102102
// Post B (originalIndex 7) should be second
103-
expect(links[1]).toHaveAttribute("href", "/blog/7");
103+
expect(links[1]).toHaveAttribute("href", "/blog/7/");
104104
// Post A (originalIndex 3) should be third
105-
expect(links[2]).toHaveAttribute("href", "/blog/3");
105+
expect(links[2]).toHaveAttribute("href", "/blog/3/");
106106
});
107107

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

122122
// First blog: use originalIndex
123-
expect(links[0]).toHaveAttribute("href", "/blog/10");
123+
expect(links[0]).toHaveAttribute("href", "/blog/10/");
124124
// Second blog: fall back to array index (1)
125-
expect(links[1]).toHaveAttribute("href", "/blog/1");
125+
expect(links[1]).toHaveAttribute("href", "/blog/1/");
126126
// Third blog: use originalIndex
127-
expect(links[2]).toHaveAttribute("href", "/blog/20");
127+
expect(links[2]).toHaveAttribute("href", "/blog/20/");
128128
});
129129

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

163163
// Links should point to original indices, not filtered positions
164-
expect(links[0]).toHaveAttribute("href", "/blog/1"); // AI Post 1 was at index 1
165-
expect(links[1]).toHaveAttribute("href", "/blog/3"); // AI Post 2 was at index 3
166-
expect(links[2]).toHaveAttribute("href", "/blog/5"); // AI Post 3 was at index 5
164+
expect(links[0]).toHaveAttribute("href", "/blog/1/"); // AI Post 1 was at index 1
165+
expect(links[1]).toHaveAttribute("href", "/blog/3/"); // AI Post 2 was at index 3
166+
expect(links[2]).toHaveAttribute("href", "/blog/5/"); // AI Post 3 was at index 5
167167
});
168168
});

website/test/EntryList.test.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ describe("EntryList Component", () => {
104104
const links = screen.getAllByRole("link");
105105

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

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

133133
const viewAllLink = screen.getByText("View all entries →");
134134
expect(viewAllLink).toBeInTheDocument();
135-
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/blog");
135+
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/blog/");
136136
});
137137

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

149-
expect(links[0]).toHaveAttribute("href", "/blog/0");
150-
expect(links[1]).toHaveAttribute("href", "/blog/1");
151-
expect(links[2]).toHaveAttribute("href", "/blog/2");
149+
expect(links[0]).toHaveAttribute("href", "/blog/0/");
150+
expect(links[1]).toHaveAttribute("href", "/blog/1/");
151+
expect(links[2]).toHaveAttribute("href", "/blog/2/");
152152
});
153153

154154
/**
@@ -187,11 +187,24 @@ describe("EntryList Component", () => {
187187

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

192192
const viewAllLink = screen.queryByText("View all entries →");
193193
if (viewAllLink) {
194-
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/quantum/basics");
194+
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/quantum/basics/");
195195
}
196196
});
197+
198+
it("normalizes basePath with trailing slash to avoid double slashes", () => {
199+
render(<EntryList {...defaultProps} basePath="/blog/" limit={2} showViewAllLink={true} />);
200+
201+
const links = screen.getAllByRole("link");
202+
// Entry links should not have double slashes
203+
expect(links[0]).toHaveAttribute("href", "/blog/0/");
204+
expect(links[1]).toHaveAttribute("href", "/blog/1/");
205+
206+
// View all link should also be clean
207+
const viewAllLink = screen.getByText("View all entries →");
208+
expect(viewAllLink.closest("a")).toHaveAttribute("href", "/blog/");
209+
});
197210
});

0 commit comments

Comments
 (0)