diff --git a/src/canonical.ts b/src/canonical.ts index 0d725af..785be4d 100644 --- a/src/canonical.ts +++ b/src/canonical.ts @@ -1,19 +1,23 @@ import type { PublicPageContext } from "emdash"; +import { applyTrailingSlash, type TrailingSlash } from "./urls.js"; const NOINDEX_PATHS = new Set(["/search"]); /** * Generate canonical URL. - * + * * - Every indexable page gets one * - Omit on 404 and noindex pages - * - Absolute, clean, with trailing slash + * - Absolute, clean, with the site's trailing-slash policy (`trailingSlash`, + * from Astro's config — shared with `buildPageUrl` so canonical and + * hreflang/sitemap URLs stay identical) * - Respect user override * - Include pagination parameter */ export function generateCanonical( page: PublicPageContext, siteUrl: string, + trailingSlash?: TrailingSlash, ): string | null { const path = page.path || "/"; @@ -30,8 +34,8 @@ export function generateCanonical( const u = new URL(page.url, siteUrl); let pathname = u.pathname.toLowerCase().replace(/\/+/g, "/"); - // Ensure trailing slash - if (!pathname.endsWith("/")) pathname += "/"; + // Apply the site's trailing-slash policy (default: keep a trailing slash). + pathname = applyTrailingSlash(pathname, trailingSlash); // Build clean URL with only pagination param const pageParam = u.searchParams.get("page"); diff --git a/src/hreflang.ts b/src/hreflang.ts index 2e2035a..53c1927 100644 --- a/src/hreflang.ts +++ b/src/hreflang.ts @@ -6,7 +6,7 @@ import type { PublicPageContext, } from "emdash"; -import { buildPageUrl } from "./urls.js"; +import { buildPageUrl, type TrailingSlash } from "./urls.js"; /** * Thin EmDash adapter around `@jdevalk/astro-seo-graph`'s @@ -30,6 +30,7 @@ export async function generateHreflang( page: PublicPageContext, ctx: PluginContext, siteUrl: string, + trailingSlash?: TrailingSlash, ): Promise { // Dynamically import emdash to keep this module testable via // `vi.mock("emdash", ...)` — the alternative is top-level imports @@ -82,6 +83,7 @@ export async function generateHreflang( siteUrl, cfg, urlPattern, + trailingSlash, }); if (href === null) continue; diff --git a/src/indexnow.ts b/src/indexnow.ts index 8cd8083..5a1039f 100644 --- a/src/indexnow.ts +++ b/src/indexnow.ts @@ -5,7 +5,7 @@ import { validateIndexNowKey, } from "@jdevalk/seo-graph-core"; import type { PluginContext } from "emdash"; -import { buildPageUrl } from "./urls.js"; +import { buildPageUrl, type TrailingSlash } from "./urls.js"; const KEY_KV = "indexnow:key"; const ENABLED_KV = "settings:indexnowEnabled"; @@ -74,6 +74,7 @@ async function urlForContent( content: Record, collection: string, siteUrl: string, + trailingSlash?: TrailingSlash, ): Promise { const slug = typeof content.slug === "string" ? content.slug : null; if (!slug) return null; @@ -109,6 +110,7 @@ async function urlForContent( siteUrl, cfg, urlPattern: info.urlPattern, + trailingSlash, }); } @@ -165,7 +167,12 @@ export async function handleIndexNowTransition( const siteUrl = ctx.site.url; if (!siteUrl) return; - const url = await urlForContent(event.content, event.collection, siteUrl); + const url = await urlForContent( + event.content, + event.collection, + siteUrl, + ctx.site.trailingSlash, + ); if (!url) return; await submitUrlToIndexNow(ctx, url); @@ -204,7 +211,12 @@ export async function handleIndexNowPublished( const siteUrl = ctx.site.url; if (!siteUrl) return; - const url = await urlForContent(event.content, event.collection, siteUrl); + const url = await urlForContent( + event.content, + event.collection, + siteUrl, + ctx.site.trailingSlash, + ); if (!url) return; // Remember where this id lives so afterDelete can ping the dead URL. diff --git a/src/llms.ts b/src/llms.ts index d8c94d0..3630511 100644 --- a/src/llms.ts +++ b/src/llms.ts @@ -127,6 +127,7 @@ export async function generateLlmsTxt(ctx: PluginContext): Promise): PluginContext { +function makeCtx( + items: Record, + trailingSlash?: "always" | "never" | "ignore", +): PluginContext { const store = new Map(); const kv = { get: async (k: string) => store.get(k), @@ -65,7 +68,12 @@ function makeCtx(items: Record): PluginContext { kv, content, log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - site: { name: "Example", url: "https://example.com", locale: "en" }, + site: { + name: "Example", + url: "https://example.com", + locale: "en", + ...(trailingSlash ? { trailingSlash } : {}), + }, url: (p: string) => `https://example.com${p}`, } as unknown as PluginContext; } @@ -138,6 +146,50 @@ describe("listSchemaEntries", () => { ]); }); + it("emits bare URLs (no trailing slash) when the site's trailingSlash is 'never'", async () => { + mockState.collections = [ + { slug: "blog", label: "Blog", urlPattern: "/blog/{slug}" }, + { slug: "pages", label: "Pages", urlPattern: "/{slug}" }, + ]; + const ctx = makeCtx( + { + blog: [ + { + id: "1", + type: "content", + slug: "hello", + status: "published", + locale: "en", + data: {}, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + }, + ], + pages: [ + { + id: "3", + type: "content", + slug: "about", + status: "published", + locale: "en", + data: {}, + createdAt: "2026-01-03T00:00:00Z", + updatedAt: "2026-01-03T00:00:00Z", + }, + ], + }, + "never", + ); + + const result = await listSchemaEntries(ctx); + + // The sitemap advertises bare URLs, matching a headless front-end that serves them. + expect(result.map((r) => r.url)).toEqual([ + "https://example.com/blog/hello", + "https://example.com/about", + ]); + }); + it("skips collections without a urlPattern", async () => { mockState.collections = [ { slug: "internal", label: "Internal" }, diff --git a/test/urls.test.ts b/test/urls.test.ts index 9eb106e..a4a2ea9 100644 --- a/test/urls.test.ts +++ b/test/urls.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { I18nConfig } from "emdash"; -import { buildPageUrl } from "../src/urls.js"; +import { applyTrailingSlash, buildPageUrl } from "../src/urls.js"; const SITE = "https://example.com"; @@ -177,4 +177,77 @@ describe("buildPageUrl", () => { ).toBe("https://example.com/fr-ca/bonjour/"); }); }); + + describe("trailingSlash policy", () => { + it('strips the trailing slash when trailingSlash is "never"', () => { + expect( + buildPageUrl({ + locale: "en", + slug: "hello", + siteUrl: SITE, + cfg: CFG_NO_PREFIX_DEFAULT, + urlPattern: "/{slug}", + trailingSlash: "never", + }), + ).toBe("https://example.com/hello"); + }); + + it('keeps the trailing slash when trailingSlash is "always"', () => { + expect( + buildPageUrl({ + locale: "en", + slug: "hello", + siteUrl: SITE, + cfg: CFG_NO_PREFIX_DEFAULT, + urlPattern: "/{slug}", + trailingSlash: "always", + }), + ).toBe("https://example.com/hello/"); + }); + + it('keeps the trailing slash when trailingSlash is "ignore" (backward-compatible default)', () => { + expect( + buildPageUrl({ + locale: "en", + slug: "hello", + siteUrl: SITE, + cfg: CFG_NO_PREFIX_DEFAULT, + urlPattern: "/{slug}", + trailingSlash: "ignore", + }), + ).toBe("https://example.com/hello/"); + }); + + it('strips the slash on multi-segment, locale-prefixed URLs when "never"', () => { + expect( + buildPageUrl({ + locale: "fr", + slug: "bonjour", + siteUrl: SITE, + cfg: CFG_NO_PREFIX_DEFAULT, + urlPattern: "/blog/{slug}", + trailingSlash: "never", + }), + ).toBe("https://example.com/fr/blog/bonjour"); + }); + }); +}); + +describe("applyTrailingSlash", () => { + it('adds a trailing slash by default (undefined) and for "always"/"ignore"', () => { + expect(applyTrailingSlash("/blog/post", undefined)).toBe("/blog/post/"); + expect(applyTrailingSlash("/blog/post", "always")).toBe("/blog/post/"); + expect(applyTrailingSlash("/blog/post", "ignore")).toBe("/blog/post/"); + }); + + it('strips trailing slashes for "never"', () => { + expect(applyTrailingSlash("/blog/post/", "never")).toBe("/blog/post"); + expect(applyTrailingSlash("/blog/post", "never")).toBe("/blog/post"); + }); + + it('keeps the site root as "/" under every policy', () => { + expect(applyTrailingSlash("/", "never")).toBe("/"); + expect(applyTrailingSlash("/", "always")).toBe("/"); + expect(applyTrailingSlash("/", undefined)).toBe("/"); + }); });