Skip to content
Open
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
12 changes: 8 additions & 4 deletions src/canonical.ts
Original file line number Diff line number Diff line change
@@ -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 || "/";

Expand All @@ -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");
Expand Down
4 changes: 3 additions & 1 deletion src/hreflang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +30,7 @@ export async function generateHreflang(
page: PublicPageContext,
ctx: PluginContext,
siteUrl: string,
trailingSlash?: TrailingSlash,
): Promise<PageMetadataContribution[]> {
// Dynamically import emdash to keep this module testable via
// `vi.mock("emdash", ...)` — the alternative is top-level imports
Expand Down Expand Up @@ -82,6 +83,7 @@ export async function generateHreflang(
siteUrl,
cfg,
urlPattern,
trailingSlash,
});
if (href === null) continue;

Expand Down
18 changes: 15 additions & 3 deletions src/indexnow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -74,6 +74,7 @@ async function urlForContent(
content: Record<string, unknown>,
collection: string,
siteUrl: string,
trailingSlash?: TrailingSlash,
): Promise<string | null> {
const slug = typeof content.slug === "string" ? content.slug : null;
if (!slug) return null;
Expand Down Expand Up @@ -109,6 +110,7 @@ async function urlForContent(
siteUrl,
cfg,
urlPattern: info.urlPattern,
trailingSlash,
});
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export async function generateLlmsTxt(ctx: PluginContext): Promise<string | null
siteUrl,
cfg,
urlPattern: collection.urlPattern,
trailingSlash: ctx.site.trailingSlash,
});
if (!url) continue;

Expand Down
4 changes: 2 additions & 2 deletions src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ export async function metadataHandler(
}

// 5. Canonical
const canonical = generateCanonical(page, siteUrl);
const canonical = generateCanonical(page, siteUrl, ctx.site.trailingSlash);
if (canonical) {
contributions.push({ kind: "link", rel: "canonical", href: canonical });
}

// 5b. hreflang alternates (multilingual content sites only)
// Short-circuits internally when i18n is disabled — zero cost on
// single-locale sites.
const hreflangContributions = await generateHreflang(page, ctx, siteUrl);
const hreflangContributions = await generateHreflang(page, ctx, siteUrl, ctx.site.trailingSlash);
contributions.push(...hreflangContributions);

// 6. Open Graph + Twitter
Expand Down
1 change: 1 addition & 0 deletions src/schema/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export async function listSchemaEntries(ctx: PluginContext): Promise<SchemaMapEn
siteUrl,
cfg,
urlPattern: collection.urlPattern,
trailingSlash: ctx.site.trailingSlash,
});
if (!url) continue;

Expand Down
51 changes: 43 additions & 8 deletions src/urls.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
import type { I18nConfig } from "emdash";

/**
* How a generated URL should treat its trailing slash. Mirrors Astro's
* `trailingSlash` config (`'always' | 'never' | 'ignore'`) so the URLs this
* plugin advertises (sitemap `schema/map`, `llms.txt`, canonical, hreflang,
* IndexNow) match what the site actually serves. This matters especially for
* headless consumers, whose front-end may not use Astro's default trailing
* slash — `ctx.site.trailingSlash` carries the site's configured value.
*/
export type TrailingSlash = "always" | "never" | "ignore";

const TRAILING_SLASHES_RE = /\/+$/;

/**
* Apply a trailing-slash policy to a path.
*
* - `"never"` strips any trailing slash (except the site root `"/"`).
* - `"always"`, `"ignore"`, or `undefined` keep/add the trailing slash — the
* historical behaviour, preserved so existing Astro-served sites are
* unchanged. (`"ignore"` maps to "keep" because a sitemap/canonical URL must
* commit to a single form, and the trailing-slash form is the pre-existing
* one; a site that wants the bare form sets `trailingSlash: "never"`.)
*
* The site root is always `"/"` regardless of policy.
*/
export function applyTrailingSlash(path: string, mode: TrailingSlash | undefined): string {
if (path === "/") return path;
if (mode === "never") return path.replace(TRAILING_SLASHES_RE, "");
return path.endsWith("/") ? path : `${path}/`;
}

/**
* Build an absolute page URL for a `(locale, slug)` pair inside a
* collection, honoring Astro's i18n locale-prefix routing rules.
Expand All @@ -22,13 +52,19 @@ export interface BuildPageUrlInput {
cfg: I18nConfig;
/** e.g. `"/{slug}"` or `"/blog/{slug}"`. */
urlPattern: string;
/**
* Trailing-slash policy for the emitted URL, from `ctx.site.trailingSlash`
* (Astro's `trailingSlash` config). Defaults to keeping the trailing slash,
* so behaviour is unchanged when the site doesn't set it.
*/
trailingSlash?: TrailingSlash;
}

const UNSUBSTITUTED_PLACEHOLDER_RE = /\{[^}]+\}/;
const MULTI_SLASH_RE = /\/+/g;

export function buildPageUrl(input: BuildPageUrlInput): string | null {
const { locale, slug, siteUrl, cfg, urlPattern } = input;
const { locale, slug, siteUrl, cfg, urlPattern, trailingSlash } = input;

if (!urlPattern.includes("{slug}")) return null;

Expand All @@ -48,17 +84,16 @@ export function buildPageUrl(input: BuildPageUrlInput): string | null {
path = `/${locale}${path}`;
}

// Normalize: ensure leading slash, lowercase, collapse duplicate
// slashes, enforce trailing slash. This matches canonical.ts's
// normalization rules — any change here must update canonical.ts
// (and vice versa) or the hreflang/canonical equality invariant
// will break.
// Normalize: ensure leading slash, lowercase, collapse duplicate slashes,
// then apply the site's trailing-slash policy. This matches canonical.ts's
// normalization (both use `applyTrailingSlash`) — the hreflang/canonical
// equality invariant depends on the two staying in sync.
if (!path.startsWith("/")) path = "/" + path;
path = path.toLowerCase().replace(MULTI_SLASH_RE, "/");
if (!path.endsWith("/")) path += "/";
path = applyTrailingSlash(path, trailingSlash);

// Strip trailing slash from siteUrl before concatenation.
const origin = siteUrl.replace(/\/+$/, "");
const origin = siteUrl.replace(TRAILING_SLASHES_RE, "");

// Defensive: verify the result is a parseable absolute URL.
try {
Expand Down
56 changes: 54 additions & 2 deletions test/schema-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ type Item = {
updatedAt: string;
};

function makeCtx(items: Record<string, Item[]>): PluginContext {
function makeCtx(
items: Record<string, Item[]>,
trailingSlash?: "always" | "never" | "ignore",
): PluginContext {
const store = new Map<string, unknown>();
const kv = {
get: async (k: string) => store.get(k),
Expand Down Expand Up @@ -65,7 +68,12 @@ function makeCtx(items: Record<string, Item[]>): 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;
}
Expand Down Expand Up @@ -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" },
Expand Down
75 changes: 74 additions & 1 deletion test/urls.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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("/");
});
});