All files / src/lib/seo pagination-canonical.ts

100% Statements 25/25
92.3% Branches 12/13
100% Functions 3/3
100% Lines 25/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77                          21x 21x             21x 16x 16x       21x 21x   21x     8x         13x 13x 12x 10x             13x 9x   13x 8x       5x 1x 1x 1x 1x 1x     1x                   4x    
export interface CanonicalInput {
	baseUrl: string;
	pathname: string;
	searchParams: URLSearchParams;
	allowedCanonicalParams?: string[];
}
 
export interface CanonicalOutput {
	canonicalUrl: string;
	robots: string;
}
 
export function computeCanonical(input: CanonicalInput): CanonicalOutput {
	const { baseUrl, pathname, searchParams, allowedCanonicalParams = [] } = input;
	const base = `${baseUrl}${pathname}`;
 
	// A noindexed page MUST be self-canonical. Pointing its canonical at a
	// *different* URL (e.g. the bare base) lets Google propagate the noindex back
	// to that target and deindex it — which would knock /pros, /projects, or page 1
	// out of the index. So every noindex branch below canonicals to the page
	// itself (path + its own query), never to `base`.
	const selfCanonical = (): string => {
		const qs = searchParams.toString();
		return qs ? `${base}?${qs}` : base;
	};
 
	// Rule 1 & 5: page param handling
	const pageRaw = searchParams.get("page");
	const pageNum = pageRaw !== null ? parseInt(pageRaw, 10) : 1;
 
	if (pageNum > 1) {
		// Rule 1: page 2+ → noindex, self-canonical (keep page in the canonical;
		// canonical-to-page-1 + noindex risks deindexing page 1).
		return { canonicalUrl: selfCanonical(), robots: "noindex, follow" };
	}
 
	// At this point page is absent, "1", or invalid (treat as 1).
	// Collect non-page params to check for filters.
	const nonPageParams: string[] = [];
	for (const key of searchParams.keys()) {
		if (key !== "page") {
			nonPageParams.push(key);
		}
	}
 
	// Rule 2: any param NOT in allowedCanonicalParams present (filter/search) →
	// noindex, self-canonical. This drops faceted-filter and internal-search
	// permutations from the index while still following links to detail pages.
	const hasDisallowedParam = nonPageParams.some(
		(key) => !allowedCanonicalParams.includes(key),
	);
	if (hasDisallowedParam) {
		return { canonicalUrl: selfCanonical(), robots: "noindex, follow" };
	}
 
	// Rule 3: only allowedCanonicalParams present → preserve them in canonical
	if (nonPageParams.length > 0) {
		const preserved = new URLSearchParams();
		for (const key of allowedCanonicalParams) {
			const values = searchParams.getAll(key);
			for (const v of values) {
				preserved.append(key, v);
			}
		}
		const qs = preserved.toString();
		// When Rule 3 applies, nonPageParams holds allowed params that exist in
		// searchParams — so `preserved` is always non-empty and the `:base` branch
		// of the ternary below is unreachable in practice.
		/* v8 ignore start -- qs is always truthy when Rule 3 applies */
		return { canonicalUrl: qs ? `${base}?${qs}` : base, robots: "index, follow" };
		/* v8 ignore stop */
	}
 
	// Rule 4: no params (or only page=1) → canonical is bare base
	return { canonicalUrl: base, robots: "index, follow" };
}