-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfacets.ts
More file actions
155 lines (138 loc) · 4.78 KB
/
Copy pathfacets.ts
File metadata and controls
155 lines (138 loc) · 4.78 KB
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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
/**
* Faceted-navigation indexability policy.
*
* Category pages multiply into near-infinite URL variants once you add color,
* size, sort, view, and tracking params. Indexing them all is how headless
* sites drown in duplicate/thin pages. This module encodes ONE deterministic
* tier policy that decides, per request, what the canonical and robots
* directives should be:
*
* - base (no facets, no sort) -> indexable, self-canonical
* - single CURATED facet (e.g. ?color=red) -> indexable, self-canonical
* - sort/view variants (e.g. ?sort=price) -> indexable but canonical -> base
* - single UNCURATED facet (e.g. ?color=lime) -> noindex, self-canonical
* - multiple facets (e.g. ?color=red&size=9) -> noindex, self-canonical
* - tracking/session params (utm_*, gclid, ...)-> excluded from canonical
* entirely (a robots/crawl-budget concern, never an indexable signal)
*
* A noindex tier is always kept self-canonical — never cross-canonicalized to
* base — because noindex + a canonical to a different URL are conflicting
* signals.
*/
export type FacetTier =
| 'base'
| 'curated-facet'
| 'sort-variant'
| 'uncurated-facet'
| 'multi-facet';
export interface FacetDecision {
tier: FacetTier;
/** Robots index directive for this URL. */
index: boolean;
/**
* The retained facet params that belong in the canonical (already filtered to
* curated facets; sort/view/tracking never appear here).
*/
canonicalParams: Record<string, string>;
/**
* Whether the canonical should point at the BASE category path (dropping the
* current facet/sort selection). When false, the canonical is self.
*/
canonicalToBase: boolean;
}
export interface FacetPolicyInput {
/** Which query params are facets (e.g. `['color', 'size']`). */
facetKeys: string[];
/** Curated facet values that are allowed to be indexable on their own. */
curatedFacets: Record<string, string[]>;
/** The raw query params from the request. */
params: Record<string, string | string[] | undefined>;
/** Param keys treated as sort/view variants. Defaults to `sort`, `view`. */
sortKeys?: string[];
}
const DEFAULT_SORT_KEYS = ['sort', 'view'];
/** Tracking/session params that must never influence the canonical. */
const TRACKING_PARAM_PATTERNS = [
/^utm_/i,
/^gclid$/i,
/^fbclid$/i,
/^msclkid$/i,
/^mc_/i,
/^ref$/i,
/^sessionid$/i,
/^sid$/i,
];
export function isTrackingParam(key: string): boolean {
return TRACKING_PARAM_PATTERNS.some((re) => re.test(key));
}
function firstValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) return value[0];
return value ?? undefined;
}
/**
* Decide the indexability tier for a faceted category request.
*/
export function decideFacetPolicy(input: FacetPolicyInput): FacetDecision {
const sortKeys = new Set(input.sortKeys ?? DEFAULT_SORT_KEYS);
const facetKeySet = new Set(input.facetKeys);
// Active facets: facet keys that carry a non-empty value.
const activeFacets: Array<{ key: string; value: string }> = [];
let hasSortVariant = false;
for (const [key, rawValue] of Object.entries(input.params)) {
if (isTrackingParam(key)) continue; // always excluded
const value = firstValue(rawValue);
if (value === undefined || value === '') continue;
if (facetKeySet.has(key)) {
activeFacets.push({ key, value });
} else if (sortKeys.has(key)) {
hasSortVariant = true;
}
// Unknown params: ignored for tier purposes (also excluded from canonical).
}
// More than one facet selection -> noindex, self-canonical.
if (activeFacets.length > 1) {
return {
tier: 'multi-facet',
index: false,
canonicalParams: {},
canonicalToBase: false,
};
}
if (activeFacets.length === 1) {
const { key, value } = activeFacets[0];
const curated = input.curatedFacets[key]?.includes(value) ?? false;
if (!curated) {
// Uncurated single facet -> noindex, self-canonical.
return {
tier: 'uncurated-facet',
index: false,
canonicalParams: {},
canonicalToBase: false,
};
}
// Curated single facet. A sort/view on top of it canonicalizes back to the
// curated facet page (dropping the sort); otherwise it is self-canonical.
return {
tier: hasSortVariant ? 'sort-variant' : 'curated-facet',
index: true,
canonicalParams: { [key]: value },
canonicalToBase: false,
};
}
// No facets. A sort/view variant on the bare base canonicalizes to base.
if (hasSortVariant) {
return {
tier: 'sort-variant',
index: true,
canonicalParams: {},
canonicalToBase: true,
};
}
// Plain base.
return {
tier: 'base',
index: true,
canonicalParams: {},
canonicalToBase: false,
};
}