diff --git a/website/blog/merkle_ai_batching.tsx b/website/blog/merkle_ai_batching.tsx index b57970bea..db94f187e 100644 --- a/website/blog/merkle_ai_batching.tsx +++ b/website/blog/merkle_ai_batching.tsx @@ -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 @@ -53,7 +54,7 @@ export default function MerkleAIBatching() {

My initial answer is now live:{" "} - Feel free to try my AI assistant - {" "} + {" "} - 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.

@@ -113,7 +114,7 @@ export default function MerkleAIBatching() {

Building on Previous Blockchain-AI Experience

- I previously built an AI image generator with blockchain payments, proving that + I previously built an AI image generator with blockchain payments, 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 "one transaction per request" model becomes prohibitively expensive and cumbersome. @@ -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 previous post on Merkle tree fundamentals. While that post covered the - mathematical foundations, here we'll focus on the practical implementation for real-time AI services. + detail in my previous post on Merkle tree fundamentals. While that post covered + the mathematical foundations, here we'll focus on the practical implementation for real-time AI services.

diff --git a/website/blog/merkle_ai_batching_fundamentals.tsx b/website/blog/merkle_ai_batching_fundamentals.tsx index fbc53ba64..910a4a20d 100644 --- a/website/blog/merkle_ai_batching_fundamentals.tsx +++ b/website/blog/merkle_ai_batching_fundamentals.tsx @@ -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 @@ -1094,7 +1095,7 @@ export default function MerkleAIBatching() {

I've already built an AI image generator that works on the blockchain - you can try it on my{" "} - image generation page. Users pay with their wallet, and my{" "} + image generation page. Users pay with their wallet, and my{" "} GenImNFT contract {" "} diff --git a/website/blog/x402_facilitator_imagegen.mdx b/website/blog/x402_facilitator_imagegen.mdx index b6c6532bd..878417771 100644 --- a/website/blog/x402_facilitator_imagegen.mdx +++ b/website/blog/x402_facilitator_imagegen.mdx @@ -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. @@ -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: diff --git a/website/components/EntryList.tsx b/website/components/EntryList.tsx index 0a37650b0..be11365f3 100644 --- a/website/components/EntryList.tsx +++ b/website/components/EntryList.tsx @@ -19,13 +19,15 @@ import { SITE } from "../utils/siteData"; */ const EntryList: React.FC = ({ 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) { @@ -39,7 +41,7 @@ const EntryList: React.FC = ({ // 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; @@ -114,7 +116,7 @@ const EntryList: React.FC = ({ {/* View All Link */} {hasMore && showViewAllLink && (

- View all entries → + View all entries →
)} diff --git a/website/components/Link.tsx b/website/components/Link.tsx index eed6870aa..ec8942e00 100644 --- a/website/components/Link.tsx +++ b/website/components/Link.tsx @@ -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; diff --git a/website/components/MetadataLine.tsx b/website/components/MetadataLine.tsx index 80e15322b..0a7f0deb1 100644 --- a/website/components/MetadataLine.tsx +++ b/website/components/MetadataLine.tsx @@ -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", diff --git a/website/pages/x402/+Page.tsx b/website/pages/x402/+Page.tsx index 0565b1104..45b186889 100644 --- a/website/pages/x402/+Page.tsx +++ b/website/pages/x402/+Page.tsx @@ -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 ───────────────────────────────────────────── @@ -389,7 +390,7 @@ return new Response(JSON.stringify(result), { status: 200 });`}

That's it — your service now accepts crypto payments. See the{" "} - agent onboarding guide for a complete walkthrough. + agent onboarding guide for a complete walkthrough.

@@ -728,7 +729,7 @@ app.post("/api/resource", async (req, res) => {

Each payment is individually signed via EIP-3009. The authorization is bound to a specific amount, recipient, and expiration. The protocol never has blanket access - to your customer's funds. See the AI Image Generator for a live example. + to your customer's funds. See the AI Image Generator for a live example.

{/* ── 9. Links ─────────────────────────────────────────────────── */} @@ -747,10 +748,10 @@ app.post("/api/resource", async (req, res) => {
  • - AI Image Generator — live x402 service using this facilitator + AI Image Generator — live x402 service using this facilitator
  • - Agent onboarding — build your own x402-protected service + Agent onboarding — build your own x402-protected service
  • diff --git a/website/public/CNAME b/website/public/CNAME new file mode 100644 index 000000000..4c9d56815 --- /dev/null +++ b/website/public/CNAME @@ -0,0 +1 @@ +www.fretchen.eu diff --git a/website/test/EntryList.filtering.test.tsx b/website/test/EntryList.filtering.test.tsx index 2ba5d8b32..5a7c3e753 100644 --- a/website/test/EntryList.filtering.test.tsx +++ b/website/test/EntryList.filtering.test.tsx @@ -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/"); }); /** @@ -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/"); }); /** @@ -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/"); }); /** @@ -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/"); }); /** @@ -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 }); }); diff --git a/website/test/EntryList.test.tsx b/website/test/EntryList.test.tsx index 39c171435..a706f1814 100644 --- a/website/test/EntryList.test.tsx +++ b/website/test/EntryList.test.tsx @@ -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/"); }); /** @@ -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/"); }); /** @@ -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/"); }); /** @@ -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(); + + 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/"); + }); }); diff --git a/website/test/LanguageToggle.test.tsx b/website/test/LanguageToggle.test.tsx index 3002e5acb..7a44670c8 100644 --- a/website/test/LanguageToggle.test.tsx +++ b/website/test/LanguageToggle.test.tsx @@ -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), }; }, })); @@ -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", () => { @@ -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", () => { @@ -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/"); }); }); @@ -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 }, ]; diff --git a/website/test/Link.test.tsx b/website/test/Link.test.tsx new file mode 100644 index 000000000..027116394 --- /dev/null +++ b/website/test/Link.test.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Link } from "../components/Link"; +import "@testing-library/jest-dom"; + +// Mock usePageContext from vike-react +const mockPageContext = { + urlPathname: "/blog/", + locale: "en", +}; + +vi.mock("vike-react/usePageContext", () => ({ + usePageContext: () => mockPageContext, +})); + +// Mock PandaCSS +vi.mock("../styled-system/css", () => ({ + css: () => "mocked-css", +})); + +describe("Link Component", () => { + describe("trailing slash behavior", () => { + it("adds trailing slash to internal paths", () => { + render(Blog); + expect(screen.getByText("Blog").closest("a")).toHaveAttribute("href", "/blog/"); + }); + + it("keeps existing trailing slash unchanged", () => { + render(Blog); + expect(screen.getByText("Blog").closest("a")).toHaveAttribute("href", "/blog/"); + }); + + it("keeps root path unchanged", () => { + render(Home); + expect(screen.getByText("Home").closest("a")).toHaveAttribute("href", "/"); + }); + + it("does not add trailing slash to file URLs", () => { + render(Data); + expect(screen.getByText("Data").closest("a")).toHaveAttribute("href", "/data.json"); + }); + + it("does not add trailing slash to URLs with hash", () => { + render(Section); + expect(screen.getByText("Section").closest("a")).toHaveAttribute("href", "/page#section"); + }); + + it("does not add trailing slash to URLs with query", () => { + render(Search); + expect(screen.getByText("Search").closest("a")).toHaveAttribute("href", "/page?q=1"); + }); + + it("adds trailing slash to nested paths", () => { + render(AMO); + expect(screen.getByText("AMO").closest("a")).toHaveAttribute("href", "/quantum/amo/"); + }); + }); + + describe("locale handling with trailing slash", () => { + it("adds locale prefix after trailing slash for non-default locale", () => { + render( + + Blog + , + ); + expect(screen.getByText("Blog").closest("a")).toHaveAttribute("href", "/de/blog/"); + }); + + it("does not add locale prefix for default locale", () => { + render( + + Blog + , + ); + expect(screen.getByText("Blog").closest("a")).toHaveAttribute("href", "/blog/"); + }); + }); + + describe("active state detection", () => { + it("marks matching path as active (bold)", () => { + // urlPathname is "/blog/" in mock + render(Blog); + const link = screen.getByText("Blog").closest("a"); + expect(link).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + render(Click me); + expect(screen.getByText("Click me")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + render( + + Styled + , + ); + const link = screen.getByText("Styled").closest("a"); + expect(link?.className).toContain("custom-class"); + }); + }); +}); diff --git a/website/test/Post.integration.test.tsx b/website/test/Post.integration.test.tsx index f8788718c..3fcf81b8e 100644 --- a/website/test/Post.integration.test.tsx +++ b/website/test/Post.integration.test.tsx @@ -11,6 +11,37 @@ vi.mock("vike-react/usePageContext", () => ({ }), })); +// Mock useSupportAction (used by MetadataLine) +vi.mock("../hooks/useSupportAction", () => ({ + useSupportAction: () => ({ + supportCount: "0", + isLoading: false, + isSuccess: false, + errorMessage: null, + isConnected: false, + handleSupport: vi.fn(), + isReadPending: false, + readError: null, + }), +})); + +// Mock useUmami (used by MetadataLine) +vi.mock("../hooks/useUmami", () => ({ + useUmami: () => ({ + trackEvent: vi.fn(), + isDisabled: true, + isDebugMode: false, + }), +})); + +// Mock useWebmentionUrls (used by Post) +vi.mock("../hooks/useWebmentionUrls", () => ({ + useWebmentionUrls: () => ({ + urlWithoutSlash: "https://www.fretchen.eu/blog/1", + urlWithSlash: "https://www.fretchen.eu/blog/1/", + }), +})); + // Mock fetch globally global.fetch = vi.fn();