@pagebridge/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cannibalization-analyzer.d.ts +38 -0
  2. package/dist/cannibalization-analyzer.d.ts.map +1 -0
  3. package/dist/cannibalization-analyzer.js +109 -0
  4. package/dist/ctr-anomaly-analyzer.d.ts +46 -0
  5. package/dist/ctr-anomaly-analyzer.d.ts.map +1 -0
  6. package/dist/ctr-anomaly-analyzer.js +118 -0
  7. package/dist/daily-metrics-collector.d.ts +12 -0
  8. package/dist/daily-metrics-collector.d.ts.map +1 -0
  9. package/dist/daily-metrics-collector.js +47 -0
  10. package/dist/decay-detector.d.ts.map +1 -1
  11. package/dist/decay-detector.js +1 -13
  12. package/dist/index.d.ts +9 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +7 -0
  15. package/dist/insight-generator.d.ts +100 -0
  16. package/dist/insight-generator.d.ts.map +1 -0
  17. package/dist/insight-generator.js +286 -0
  18. package/dist/insight-writer.d.ts +26 -0
  19. package/dist/insight-writer.d.ts.map +1 -0
  20. package/dist/insight-writer.js +87 -0
  21. package/dist/publishing-impact-analyzer.d.ts +23 -0
  22. package/dist/publishing-impact-analyzer.d.ts.map +1 -0
  23. package/dist/publishing-impact-analyzer.js +79 -0
  24. package/dist/quick-win-analyzer.d.ts.map +1 -1
  25. package/dist/quick-win-analyzer.js +1 -8
  26. package/dist/site-insight-analyzer.d.ts +45 -0
  27. package/dist/site-insight-analyzer.d.ts.map +1 -0
  28. package/dist/site-insight-analyzer.js +144 -0
  29. package/dist/sync-engine.d.ts +34 -3
  30. package/dist/sync-engine.d.ts.map +1 -1
  31. package/dist/sync-engine.js +54 -10
  32. package/dist/url-matcher.d.ts +22 -3
  33. package/dist/url-matcher.d.ts.map +1 -1
  34. package/dist/url-matcher.js +128 -109
  35. package/dist/utils/date-utils.d.ts +4 -0
  36. package/dist/utils/date-utils.d.ts.map +1 -0
  37. package/dist/utils/date-utils.js +13 -0
  38. package/dist/utils/sanity-key.d.ts +6 -0
  39. package/dist/utils/sanity-key.d.ts.map +1 -0
  40. package/dist/utils/sanity-key.js +8 -0
  41. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ import { searchAnalytics, queryAnalytics } from "@pagebridge/db";
2
+ import { and, eq, gte, lte, sql } from "drizzle-orm";
3
+ import { daysAgo, formatDate } from "./utils/date-utils.js";
4
+ export class SiteInsightAnalyzer {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ async analyze(siteId, allPages) {
10
+ const [topPerformers, zeroClickPages, orphanPages, newKeywords] = await Promise.all([
11
+ this.getTopPerformers(siteId),
12
+ this.getZeroClickPages(siteId),
13
+ this.getOrphanPages(siteId, allPages),
14
+ this.getNewKeywordOpportunities(siteId),
15
+ ]);
16
+ return {
17
+ topPerformers,
18
+ zeroClickPages,
19
+ orphanPages,
20
+ newKeywordOpportunities: newKeywords,
21
+ };
22
+ }
23
+ async getTopPerformers(siteId) {
24
+ const startDate = daysAgo(28);
25
+ const endDate = daysAgo(3);
26
+ const results = await this.db
27
+ .select({
28
+ page: searchAnalytics.page,
29
+ totalClicks: sql `sum(${searchAnalytics.clicks})`,
30
+ totalImpressions: sql `sum(${searchAnalytics.impressions})`,
31
+ avgPosition: sql `avg(${searchAnalytics.position})`,
32
+ })
33
+ .from(searchAnalytics)
34
+ .where(and(eq(searchAnalytics.siteId, siteId), gte(searchAnalytics.date, formatDate(startDate)), lte(searchAnalytics.date, formatDate(endDate))))
35
+ .groupBy(searchAnalytics.page);
36
+ return results
37
+ .map((r) => ({
38
+ page: r.page,
39
+ clicks: Number(r.totalClicks) || 0,
40
+ impressions: Number(r.totalImpressions) || 0,
41
+ position: Number(r.avgPosition) || 0,
42
+ }))
43
+ .sort((a, b) => b.clicks - a.clicks)
44
+ .slice(0, 20);
45
+ }
46
+ async getZeroClickPages(siteId) {
47
+ const startDate = daysAgo(28);
48
+ const endDate = daysAgo(3);
49
+ const results = await this.db
50
+ .select({
51
+ page: searchAnalytics.page,
52
+ totalClicks: sql `sum(${searchAnalytics.clicks})`,
53
+ totalImpressions: sql `sum(${searchAnalytics.impressions})`,
54
+ avgPosition: sql `avg(${searchAnalytics.position})`,
55
+ })
56
+ .from(searchAnalytics)
57
+ .where(and(eq(searchAnalytics.siteId, siteId), gte(searchAnalytics.date, formatDate(startDate)), lte(searchAnalytics.date, formatDate(endDate))))
58
+ .groupBy(searchAnalytics.page);
59
+ return results
60
+ .filter((r) => {
61
+ const impressions = Number(r.totalImpressions) || 0;
62
+ const clicks = Number(r.totalClicks) || 0;
63
+ return impressions >= 100 && clicks <= 2;
64
+ })
65
+ .map((r) => ({
66
+ page: r.page,
67
+ impressions: Number(r.totalImpressions) || 0,
68
+ clicks: Number(r.totalClicks) || 0,
69
+ position: Number(r.avgPosition) || 0,
70
+ }))
71
+ .sort((a, b) => b.impressions - a.impressions);
72
+ }
73
+ async getOrphanPages(siteId, allPages) {
74
+ const startDate = daysAgo(28);
75
+ const endDate = daysAgo(3);
76
+ // Get pages that have had impressions in last 28 days
77
+ const activeResults = await this.db
78
+ .select({
79
+ page: searchAnalytics.page,
80
+ })
81
+ .from(searchAnalytics)
82
+ .where(and(eq(searchAnalytics.siteId, siteId), gte(searchAnalytics.date, formatDate(startDate)), lte(searchAnalytics.date, formatDate(endDate))))
83
+ .groupBy(searchAnalytics.page);
84
+ const activePages = new Set(activeResults.map((r) => r.page));
85
+ const orphanPageUrls = allPages.filter((page) => !activePages.has(page));
86
+ if (orphanPageUrls.length === 0)
87
+ return [];
88
+ // Query the last impression date for each orphan page
89
+ const lastImpressionResults = await this.db
90
+ .select({
91
+ page: searchAnalytics.page,
92
+ lastDate: sql `max(${searchAnalytics.date})`,
93
+ })
94
+ .from(searchAnalytics)
95
+ .where(and(eq(searchAnalytics.siteId, siteId), sql `${searchAnalytics.page} IN (${sql.join(orphanPageUrls.map((u) => sql `${u}`), sql `, `)})`))
96
+ .groupBy(searchAnalytics.page);
97
+ const lastImpressionMap = new Map();
98
+ for (const row of lastImpressionResults) {
99
+ if (row.lastDate)
100
+ lastImpressionMap.set(row.page, row.lastDate);
101
+ }
102
+ return orphanPageUrls.map((page) => ({
103
+ page,
104
+ lastImpression: lastImpressionMap.get(page),
105
+ }));
106
+ }
107
+ async getNewKeywordOpportunities(siteId) {
108
+ const recentStart = daysAgo(7);
109
+ const recentEnd = daysAgo(0);
110
+ const historicStart = daysAgo(90);
111
+ const historicEnd = daysAgo(14);
112
+ // Get queries from last 7 days
113
+ const recentQueries = await this.db
114
+ .select({
115
+ query: queryAnalytics.query,
116
+ page: queryAnalytics.page,
117
+ totalImpressions: sql `sum(${queryAnalytics.impressions})`,
118
+ avgPosition: sql `avg(${queryAnalytics.position})`,
119
+ })
120
+ .from(queryAnalytics)
121
+ .where(and(eq(queryAnalytics.siteId, siteId), gte(queryAnalytics.date, formatDate(recentStart)), lte(queryAnalytics.date, formatDate(recentEnd))))
122
+ .groupBy(queryAnalytics.query, queryAnalytics.page);
123
+ // Get queries seen in days 14-90
124
+ const historicQueryResults = await this.db
125
+ .select({
126
+ query: queryAnalytics.query,
127
+ })
128
+ .from(queryAnalytics)
129
+ .where(and(eq(queryAnalytics.siteId, siteId), gte(queryAnalytics.date, formatDate(historicStart)), lte(queryAnalytics.date, formatDate(historicEnd))))
130
+ .groupBy(queryAnalytics.query);
131
+ const historicQueries = new Set(historicQueryResults.map((r) => r.query));
132
+ return recentQueries
133
+ .filter((r) => !historicQueries.has(r.query) &&
134
+ (Number(r.totalImpressions) || 0) >= 10)
135
+ .map((r) => ({
136
+ query: r.query,
137
+ page: r.page,
138
+ impressions: Number(r.totalImpressions) || 0,
139
+ position: Number(r.avgPosition) || 0,
140
+ }))
141
+ .sort((a, b) => b.impressions - a.impressions)
142
+ .slice(0, 50);
143
+ }
144
+ }
@@ -2,6 +2,39 @@ import type { DrizzleClient } from "@pagebridge/db";
2
2
  import type { SanityClient } from "@sanity/client";
3
3
  import type { GSCClient, IndexStatusResult } from "./gsc-client.js";
4
4
  import type { QuickWinQuery } from "./quick-win-analyzer.js";
5
+ import type { CtrAnomaly } from "./ctr-anomaly-analyzer.js";
6
+ export interface PublishingImpact {
7
+ lastEditedAt: string;
8
+ daysSinceEdit: number;
9
+ positionBefore: number;
10
+ positionAfter: number;
11
+ positionDelta: number;
12
+ clicksBefore: number;
13
+ clicksAfter: number;
14
+ impressionsBefore: number;
15
+ impressionsAfter: number;
16
+ ctrBefore: number;
17
+ ctrAfter: number;
18
+ }
19
+ export interface CannibalizationTarget {
20
+ competingPage: string;
21
+ competingDocumentId: string;
22
+ sharedQueries: string[];
23
+ }
24
+ export interface DailyMetricPoint {
25
+ date: string;
26
+ clicks: number;
27
+ impressions: number;
28
+ position: number;
29
+ }
30
+ export interface SnapshotInsights {
31
+ quickWins?: Map<string, QuickWinQuery[]>;
32
+ ctrAnomalies?: Map<string, CtrAnomaly>;
33
+ dailyMetrics?: Map<string, DailyMetricPoint[]>;
34
+ publishingImpact?: Map<string, PublishingImpact>;
35
+ cannibalizationTargets?: Map<string, CannibalizationTarget[]>;
36
+ decayPages?: Set<string>;
37
+ }
5
38
  export interface SyncOptions {
6
39
  siteUrl: string;
7
40
  startDate?: Date;
@@ -34,9 +67,7 @@ export declare class SyncEngine {
34
67
  writeSnapshots(siteId: string, matches: {
35
68
  gscUrl: string;
36
69
  sanityId: string | undefined;
37
- }[], siteUrl?: string, insights?: {
38
- quickWins?: Map<string, QuickWinQuery[]>;
39
- }, onProgress?: (message: string) => void): Promise<void>;
70
+ }[], siteUrl?: string, insights?: SnapshotInsights, onProgress?: (message: string) => void): Promise<void>;
40
71
  syncIndexStatus(siteUrl: string, pages: string[]): Promise<IndexStatusSyncResult>;
41
72
  getIndexStatus(siteUrl: string, page: string): Promise<IndexStatusResult | null>;
42
73
  private getAggregatedMetrics;
@@ -1 +1 @@
1
- {"version":3,"file":"sync-engine.d.ts","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAQpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,OAAO,CAAC,EAAE,IAAI,CAAC;IACf,UAAU,CAAC,EAAE,CAAC,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC;IAC3C,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,SAAS,CAAC;IACf,EAAE,EAAE,aAAa,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,GAAG,CAAY;IACvB,OAAO,CAAC,EAAE,CAAgB;IAC1B,OAAO,CAAC,MAAM,CAAe;gBAEjB,OAAO,EAAE,iBAAiB;IAMhC,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC;IAgK/C,cAAc,CAClB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,EAAE,EAC3D,OAAO,CAAC,EAAE,MAAM,EAChB,QAAQ,CAAC,EAAE;QACT,SAAS,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;KAC1C,EACD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GACrC,OAAO,CAAC,IAAI,CAAC;IAqIV,eAAe,CACnB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EAAE,GACd,OAAO,CAAC,qBAAqB,CAAC;IA6E3B,cAAc,CAClB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YAqBtB,oBAAoB;YAiDpB,aAAa;CAyD5B"}
1
+ {"version":3,"file":"sync-engine.d.ts","sourceRoot":"","sources":["../src/sync-engine.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,8 @@
1
1
  import { searchAnalytics, queryAnalytics, syncLog, pageIndexStatus, } from "@pagebridge/db";
2
2
  import { and, eq, gte, lte, sql } from "drizzle-orm";
3
+ import { CtrAnomalyAnalyzer } from "./ctr-anomaly-analyzer.js";
4
+ import { daysAgo, formatDate } from "./utils/date-utils.js";
5
+ import { sanityKey } from "./utils/sanity-key.js";
3
6
  export class SyncEngine {
4
7
  gsc;
5
8
  db;
@@ -187,9 +190,46 @@ export class SyncEngine {
187
190
  if (!metrics)
188
191
  continue;
189
192
  const indexStatusData = indexStatusMap.get(match.gscUrl);
190
- const quickWinQueries = period === "last28"
193
+ const isLast28 = period === "last28";
194
+ const quickWinQueries = isLast28
191
195
  ? (insights?.quickWins?.get(match.gscUrl) ?? [])
192
196
  : [];
197
+ const ctrAnomaly = isLast28
198
+ ? insights?.ctrAnomalies?.get(match.gscUrl)
199
+ : undefined;
200
+ const dailyClicks = isLast28
201
+ ? insights?.dailyMetrics?.get(match.gscUrl)
202
+ : undefined;
203
+ const publishingImpact = isLast28
204
+ ? insights?.publishingImpact?.get(match.gscUrl)
205
+ : undefined;
206
+ const cannibalizationTargets = isLast28
207
+ ? insights?.cannibalizationTargets?.get(match.gscUrl)
208
+ : undefined;
209
+ // Build alerts from all insight sources
210
+ const hasQuickWins = quickWinQueries.length > 0;
211
+ const hasDecay = insights?.decayPages?.has(match.gscUrl) ?? false;
212
+ const hasCannibalization = (cannibalizationTargets?.length ?? 0) > 0;
213
+ const alerts = isLast28
214
+ ? CtrAnomalyAnalyzer.buildAlerts(ctrAnomaly, hasQuickWins, hasDecay, hasCannibalization)
215
+ : [];
216
+ // Add _key to all array items for Sanity
217
+ const keyedQuickWins = quickWinQueries.map((q) => ({
218
+ _key: sanityKey(`qw:${q.query}`),
219
+ ...q,
220
+ }));
221
+ const keyedAlerts = alerts.map((a) => ({
222
+ _key: sanityKey(`al:${a.type}:${a.severity}`),
223
+ ...a,
224
+ }));
225
+ const keyedDailyClicks = dailyClicks?.map((d) => ({
226
+ _key: sanityKey(`dc:${d.date}`),
227
+ ...d,
228
+ }));
229
+ const keyedCannibalization = cannibalizationTargets?.map((t) => ({
230
+ _key: sanityKey(`ct:${t.competingPage}`),
231
+ ...t,
232
+ }));
193
233
  const snapshotData = {
194
234
  _type: "gscSnapshot",
195
235
  site: { _type: "reference", _ref: siteId },
@@ -204,7 +244,18 @@ export class SyncEngine {
204
244
  ctr: metrics.ctr,
205
245
  position: metrics.position,
206
246
  topQueries,
207
- ...(quickWinQueries.length > 0 ? { quickWinQueries } : {}),
247
+ ...(keyedQuickWins.length > 0
248
+ ? { quickWinQueries: keyedQuickWins }
249
+ : {}),
250
+ ...(ctrAnomaly ? { ctrAnomaly } : {}),
251
+ ...(keyedAlerts.length > 0 ? { alerts: keyedAlerts } : {}),
252
+ ...(keyedDailyClicks && keyedDailyClicks.length > 0
253
+ ? { dailyClicks: keyedDailyClicks }
254
+ : {}),
255
+ ...(publishingImpact ? { publishingImpact } : {}),
256
+ ...(keyedCannibalization && keyedCannibalization.length > 0
257
+ ? { cannibalizationTargets: keyedCannibalization }
258
+ : {}),
208
259
  fetchedAt: new Date().toISOString(),
209
260
  indexStatus: indexStatusData
210
261
  ? {
@@ -368,6 +419,7 @@ export class SyncEngine {
368
419
  }
369
420
  return Array.from(queryMap.entries())
370
421
  .map(([query, data]) => ({
422
+ _key: sanityKey(`tq:${query}`),
371
423
  query,
372
424
  clicks: data.clicks,
373
425
  impressions: data.impressions,
@@ -377,14 +429,6 @@ export class SyncEngine {
377
429
  .slice(0, 10);
378
430
  }
379
431
  }
380
- function daysAgo(days) {
381
- const date = new Date();
382
- date.setDate(date.getDate() - days);
383
- return date;
384
- }
385
- function formatDate(date) {
386
- return date.toISOString().split("T")[0];
387
- }
388
432
  function delay(ms) {
389
433
  return new Promise((resolve) => setTimeout(resolve, ms));
390
434
  }
@@ -14,17 +14,37 @@ export interface MatchResult {
14
14
  matchedSlug?: string;
15
15
  unmatchReason: UnmatchReason;
16
16
  extractedSlug?: string;
17
+ matchedContentType?: string;
17
18
  diagnostics?: MatchDiagnostics;
18
19
  }
19
- export interface URLMatcherConfig {
20
- contentTypes: string[];
20
+ /**
21
+ * Configuration for a single content type's URL structure
22
+ */
23
+ export interface ContentTypeUrlConfig {
24
+ contentType: string;
25
+ pathPrefix?: string;
21
26
  slugField: string;
27
+ }
28
+ /**
29
+ * URLMatcher configuration with support for multiple content types and path structures
30
+ * @deprecated If using the old flat structure (contentTypes, slugField, pathPrefix),
31
+ * migrate to urlConfigs for more flexible URL path handling.
32
+ */
33
+ export interface URLMatcherConfig {
34
+ /** New format: array of per-content-type URL configs (recommended) */
35
+ urlConfigs?: ContentTypeUrlConfig[];
36
+ /** @deprecated Use urlConfigs instead. Kept for backward compatibility. */
37
+ contentTypes?: string[];
38
+ /** @deprecated Use urlConfigs instead. Kept for backward compatibility. */
39
+ slugField?: string;
40
+ /** @deprecated Use urlConfigs instead. Kept for backward compatibility. */
22
41
  pathPrefix?: string;
23
42
  baseUrl: string;
24
43
  }
25
44
  export declare class URLMatcher {
26
45
  private sanityClient;
27
46
  private config;
47
+ private normalizedConfigs;
28
48
  constructor(sanityClient: SanityClient, config: URLMatcherConfig);
29
49
  matchUrls(gscUrls: string[]): Promise<MatchResult[]>;
30
50
  /**
@@ -33,7 +53,6 @@ export declare class URLMatcher {
33
53
  getAvailableSlugs(): Promise<string[]>;
34
54
  private matchSingleUrl;
35
55
  private normalizeUrl;
36
- private extractSlug;
37
56
  private extractSlugWithDiagnostics;
38
57
  private escapeRegex;
39
58
  private normalizeSlug;
@@ -1 +1 @@
1
- {"version":3,"file":"url-matcher.d.ts","sourceRoot":"","sources":["../src/url-matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAC;AAE1B,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,UAAU,EAAE,OAAO,GAAG,YAAY,GAAG,OAAO,GAAG,MAAM,CAAC;IACtD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,gBAAgB,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB;AAQD,qBAAa,UAAU;IAEnB,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,MAAM;gBADN,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,gBAAgB;IAG5B,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAyB1D;;OAEG;IACG,iBAAiB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAc5C,OAAO,CAAC,cAAc;IAoGtB,OAAO,CAAC,YAAY;IAYpB,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,0BAA0B;IA0ClC,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,aAAa;IAIrB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAiBxB;;OAEG;IACH,OAAO,CAAC,mBAAmB;CA8B5B"}
1
+ {"version":3,"file":"url-matcher.d.ts","sourceRoot":"","sources":["../src/url-matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,mBAAmB,GACnB,sBAAsB,GACtB,qBAAqB,CAAC;AAE1B,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,UAAU,EAAE,OAAO,GAAG,YAAY,GAAG,OAAO,GAAG,MAAM,CAAC;IACtD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,WAAW,CAAC,EAAE,gBAAgB,CAAC;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,UAAU,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACpC,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB;AASD,qBAAa,UAAU;IAInB,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,MAAM;IAJhB,OAAO,CAAC,iBAAiB,CAAyB;gBAGxC,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,gBAAgB;IAuB5B,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAuC1D;;OAEG;IACG,iBAAiB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAsB5C,OAAO,CAAC,cAAc;IA0GtB,OAAO,CAAC,YAAY;IAYpB,OAAO,CAAC,0BAA0B;IA2ClC,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,aAAa;IAIrB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAiBxB;;OAEG;IACH,OAAO,CAAC,mBAAmB;CA8B5B"}
@@ -1,130 +1,152 @@
1
1
  export class URLMatcher {
2
2
  sanityClient;
3
3
  config;
4
+ normalizedConfigs;
4
5
  constructor(sanityClient, config) {
5
6
  this.sanityClient = sanityClient;
6
7
  this.config = config;
8
+ // Normalize config: convert old format to new format if needed
9
+ if (config.urlConfigs) {
10
+ this.normalizedConfigs = config.urlConfigs;
11
+ }
12
+ else if (config.contentTypes) {
13
+ // Backward compatibility: convert old flat format to new format
14
+ console.warn("[URLMatcher] Deprecated: contentTypes, slugField, and pathPrefix are deprecated. " +
15
+ "Please use urlConfigs instead for more flexible URL path handling per content type.");
16
+ this.normalizedConfigs = config.contentTypes.map((contentType) => ({
17
+ contentType,
18
+ slugField: config.slugField || "slug",
19
+ pathPrefix: config.pathPrefix,
20
+ }));
21
+ }
22
+ else {
23
+ throw new Error("URLMatcher requires either 'urlConfigs' (new format) or 'contentTypes' (deprecated format)");
24
+ }
7
25
  }
8
26
  async matchUrls(gscUrls) {
9
- const query = `*[_type in $types]{
10
- _id,
11
- _type,
12
- "${this.config.slugField}": ${this.config.slugField}.current,
13
- _createdAt
14
- }`;
15
- const documents = await this.sanityClient.fetch(query, {
16
- types: this.config.contentTypes,
17
- });
18
- const slugToDoc = new Map();
27
+ // Query documents for each content type with its configured slug field
28
+ const slugToDocMap = new Map();
19
29
  const allSlugs = [];
20
- for (const doc of documents) {
21
- const slug = doc[this.config.slugField];
22
- if (slug) {
23
- const normalized = this.normalizeSlug(slug);
24
- slugToDoc.set(normalized, doc);
25
- allSlugs.push(normalized);
30
+ for (const urlConfig of this.normalizedConfigs) {
31
+ const query = `*[_type == $type]{
32
+ _id,
33
+ _type,
34
+ "${urlConfig.slugField}": ${urlConfig.slugField}.current,
35
+ _createdAt
36
+ }`;
37
+ const documents = await this.sanityClient.fetch(query, {
38
+ type: urlConfig.contentType,
39
+ });
40
+ for (const doc of documents) {
41
+ const slug = doc[urlConfig.slugField];
42
+ if (slug) {
43
+ const normalized = this.normalizeSlug(slug);
44
+ const key = `${urlConfig.contentType}:${normalized}`;
45
+ slugToDocMap.set(key, {
46
+ _id: doc._id,
47
+ _createdAt: doc._createdAt,
48
+ contentType: urlConfig.contentType,
49
+ });
50
+ allSlugs.push(normalized);
51
+ }
26
52
  }
27
53
  }
28
- return gscUrls.map((url) => this.matchSingleUrl(url, slugToDoc, allSlugs));
54
+ return gscUrls.map((url) => this.matchSingleUrl(url, slugToDocMap, allSlugs));
29
55
  }
30
56
  /**
31
57
  * Get all available slugs from Sanity for diagnostic purposes
32
58
  */
33
59
  async getAvailableSlugs() {
34
- const query = `*[_type in $types]{
35
- "${this.config.slugField}": ${this.config.slugField}.current
36
- }`;
37
- const documents = await this.sanityClient.fetch(query, {
38
- types: this.config.contentTypes,
39
- });
40
- return documents
41
- .map((doc) => doc[this.config.slugField])
42
- .filter((slug) => !!slug)
43
- .map((slug) => this.normalizeSlug(slug));
60
+ const allSlugs = [];
61
+ for (const urlConfig of this.normalizedConfigs) {
62
+ const query = `*[_type == $type]{
63
+ "${urlConfig.slugField}": ${urlConfig.slugField}.current
64
+ }`;
65
+ const documents = await this.sanityClient.fetch(query, {
66
+ type: urlConfig.contentType,
67
+ });
68
+ const slugs = documents
69
+ .map((doc) => doc[urlConfig.slugField])
70
+ .filter((slug) => !!slug)
71
+ .map((slug) => this.normalizeSlug(slug));
72
+ allSlugs.push(...slugs);
73
+ }
74
+ return allSlugs;
44
75
  }
45
- matchSingleUrl(gscUrl, slugToDoc, allSlugs) {
76
+ matchSingleUrl(gscUrl, slugToDocMap, allSlugs) {
46
77
  const normalized = this.normalizeUrl(gscUrl);
47
- const extractionResult = this.extractSlugWithDiagnostics(normalized);
48
- // Check if URL is outside path prefix
49
- if (extractionResult.outsidePrefix) {
50
- return {
51
- gscUrl,
52
- sanityId: undefined,
53
- confidence: "none",
54
- unmatchReason: "outside_path_prefix",
55
- diagnostics: {
56
- normalizedUrl: normalized,
57
- pathAfterPrefix: null,
58
- configuredPrefix: this.config.pathPrefix ?? null,
59
- availableSlugsCount: slugToDoc.size,
60
- similarSlugs: [],
61
- },
62
- };
63
- }
64
- const slug = extractionResult.slug;
65
- if (!slug) {
66
- return {
67
- gscUrl,
68
- sanityId: undefined,
69
- confidence: "none",
70
- unmatchReason: "no_slug_extracted",
71
- diagnostics: {
72
- normalizedUrl: normalized,
73
- pathAfterPrefix: extractionResult.pathAfterPrefix,
74
- configuredPrefix: this.config.pathPrefix ?? null,
75
- availableSlugsCount: slugToDoc.size,
76
- similarSlugs: [],
77
- },
78
- };
79
- }
80
- const exactMatch = slugToDoc.get(slug);
81
- if (exactMatch) {
82
- return {
83
- gscUrl,
84
- sanityId: exactMatch._id,
85
- confidence: "exact",
86
- matchedSlug: slug,
87
- unmatchReason: "matched",
88
- extractedSlug: slug,
89
- };
90
- }
91
- const withoutTrailing = slug.replace(/\/$/, "");
92
- const trailingMatch = slugToDoc.get(withoutTrailing);
93
- if (trailingMatch) {
94
- return {
95
- gscUrl,
96
- sanityId: trailingMatch._id,
97
- confidence: "normalized",
98
- matchedSlug: withoutTrailing,
99
- unmatchReason: "matched",
100
- extractedSlug: slug,
101
- };
102
- }
103
- const withTrailing = slug + "/";
104
- const addedTrailingMatch = slugToDoc.get(withTrailing);
105
- if (addedTrailingMatch) {
106
- return {
107
- gscUrl,
108
- sanityId: addedTrailingMatch._id,
109
- confidence: "normalized",
110
- matchedSlug: withTrailing,
111
- unmatchReason: "matched",
112
- extractedSlug: slug,
113
- };
78
+ // Try to match against each content type's path prefix
79
+ for (const urlConfig of this.normalizedConfigs) {
80
+ const extractionResult = this.extractSlugWithDiagnostics(normalized, urlConfig.pathPrefix);
81
+ if (extractionResult.outsidePrefix) {
82
+ continue; // Try next content type
83
+ }
84
+ const slug = extractionResult.slug;
85
+ if (!slug) {
86
+ continue; // Try next content type
87
+ }
88
+ // Try exact match
89
+ const key = `${urlConfig.contentType}:${slug}`;
90
+ const exactMatch = slugToDocMap.get(key);
91
+ if (exactMatch) {
92
+ return {
93
+ gscUrl,
94
+ sanityId: exactMatch._id,
95
+ confidence: "exact",
96
+ matchedSlug: slug,
97
+ matchedContentType: urlConfig.contentType,
98
+ unmatchReason: "matched",
99
+ extractedSlug: slug,
100
+ };
101
+ }
102
+ // Try without trailing slash
103
+ const withoutTrailing = slug.replace(/\/$/, "");
104
+ const keyWithoutTrailing = `${urlConfig.contentType}:${withoutTrailing}`;
105
+ const trailingMatch = slugToDocMap.get(keyWithoutTrailing);
106
+ if (trailingMatch) {
107
+ return {
108
+ gscUrl,
109
+ sanityId: trailingMatch._id,
110
+ confidence: "normalized",
111
+ matchedSlug: withoutTrailing,
112
+ matchedContentType: urlConfig.contentType,
113
+ unmatchReason: "matched",
114
+ extractedSlug: slug,
115
+ };
116
+ }
117
+ // Try with trailing slash
118
+ const withTrailing = slug + "/";
119
+ const keyWithTrailing = `${urlConfig.contentType}:${withTrailing}`;
120
+ const addedTrailingMatch = slugToDocMap.get(keyWithTrailing);
121
+ if (addedTrailingMatch) {
122
+ return {
123
+ gscUrl,
124
+ sanityId: addedTrailingMatch._id,
125
+ confidence: "normalized",
126
+ matchedSlug: withTrailing,
127
+ matchedContentType: urlConfig.contentType,
128
+ unmatchReason: "matched",
129
+ extractedSlug: slug,
130
+ };
131
+ }
114
132
  }
115
- // No match found - find similar slugs for suggestions
116
- const similarSlugs = this.findSimilarSlugs(slug, allSlugs, 3);
133
+ // No match found across any content type - return diagnostic info
134
+ const firstConfig = this.normalizedConfigs[0];
135
+ const firstExtractionResult = this.extractSlugWithDiagnostics(normalized, firstConfig?.pathPrefix);
136
+ const similarSlugs = this.findSimilarSlugs(firstExtractionResult.slug || "", allSlugs, 3);
117
137
  return {
118
138
  gscUrl,
119
139
  sanityId: undefined,
120
140
  confidence: "none",
121
- unmatchReason: "no_matching_document",
122
- extractedSlug: slug,
141
+ unmatchReason: firstExtractionResult.outsidePrefix
142
+ ? "outside_path_prefix"
143
+ : "no_matching_document",
144
+ extractedSlug: firstExtractionResult.slug,
123
145
  diagnostics: {
124
146
  normalizedUrl: normalized,
125
- pathAfterPrefix: extractionResult.pathAfterPrefix,
126
- configuredPrefix: this.config.pathPrefix ?? null,
127
- availableSlugsCount: slugToDoc.size,
147
+ pathAfterPrefix: firstExtractionResult.pathAfterPrefix,
148
+ configuredPrefix: firstConfig?.pathPrefix ?? null,
149
+ availableSlugsCount: slugToDocMap.size,
128
150
  similarSlugs,
129
151
  },
130
152
  };
@@ -141,16 +163,13 @@ export class URLMatcher {
141
163
  return url.toLowerCase();
142
164
  }
143
165
  }
144
- extractSlug(normalizedUrl) {
145
- return this.extractSlugWithDiagnostics(normalizedUrl).slug;
146
- }
147
- extractSlugWithDiagnostics(normalizedUrl) {
166
+ extractSlugWithDiagnostics(normalizedUrl, pathPrefix) {
148
167
  try {
149
168
  const parsed = new URL(normalizedUrl);
150
169
  let path = parsed.pathname;
151
170
  // Check if the URL is outside the configured path prefix
152
- if (this.config.pathPrefix) {
153
- const prefixRegex = new RegExp(`^${this.escapeRegex(this.config.pathPrefix)}(/|$)`);
171
+ if (pathPrefix) {
172
+ const prefixRegex = new RegExp(`^${this.escapeRegex(pathPrefix)}(/|$)`);
154
173
  if (!prefixRegex.test(path)) {
155
174
  return {
156
175
  slug: undefined,
@@ -158,7 +177,7 @@ export class URLMatcher {
158
177
  outsidePrefix: true,
159
178
  };
160
179
  }
161
- path = path.replace(new RegExp(`^${this.escapeRegex(this.config.pathPrefix)}`), "");
180
+ path = path.replace(new RegExp(`^${this.escapeRegex(pathPrefix)}`), "");
162
181
  }
163
182
  const slug = path.replace(/^\/+|\/+$/g, "");
164
183
  return {
@@ -0,0 +1,4 @@
1
+ export declare function daysAgo(days: number): Date;
2
+ export declare function formatDate(date: Date): string;
3
+ export declare function daysSince(date: Date): number;
4
+ //# sourceMappingURL=date-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"date-utils.d.ts","sourceRoot":"","sources":["../../src/utils/date-utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAI1C;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAE7C;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAI5C"}