@mohasinac/appkit 4.67.0 → 4.68.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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.67.0",
4
- "head": "a745f11c80fa634f73acc9013502f5bcc17972db",
3
+ "version": "4.68.0",
4
+ "head": "d9da5b62aa583840cb50fa07288dc03177d1d991",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-26T11:54:19.893Z"
7
+ "builtAt": "2026-10-26T21:11:44.047Z"
8
8
  }
@@ -16,8 +16,8 @@ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
16
16
  import { PRODUCT_DETAIL_LISTING_TYPES } from "./product-detail-types.js";
17
17
  import { safeRead } from "../../../../errors/safe-read.js";
18
18
  import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
19
- import { ALL_BROWSE_PAGES, ALL_LISTING_TYPES, browsePageForPath, isBrowsePageEnabled, isListingSurfaceVisible, } from "../../../shared/listing-types/feature-flags.js";
20
- import { pluginFor } from "../../../shared/listing-types/_registry.js";
19
+ import { ALL_BROWSE_PAGES, browsePageForPath, isBrowsePageEnabled, } from "../../../shared/listing-types/feature-flags.js";
20
+ import { hiddenSurfaceRoutes } from "../../../shared/listing-types/hidden-surfaces.js";
21
21
  import { getSurfaceCounts } from "../products/surface-counts.js";
22
22
  // Product field strings — matches consumer field-names.ts
23
23
  const PRODUCT_STATUS = "status";
@@ -667,19 +667,14 @@ export async function buildSitemap({ baseUrl }) {
667
667
  * the sitemap rather than being withdrawn by a job that has not run.
668
668
  */
669
669
  const counts = await getSurfaceCounts();
670
- const hiddenSurfacePaths = new Set(ALL_LISTING_TYPES.filter((t) => !isListingSurfaceVisible(t, settings, counts))
671
- .map((t) => String(pluginFor(t).browseRoute ?? ""))
672
- .filter(Boolean));
673
670
  /*
674
- * A route shared by several types survives while ANY of them is visible —
675
- * `/art` spans art and stickers. Without this, hiding stickers alone would
676
- * delist a page art still fills.
671
+ * 🛑 ONE definition, shared with the edge gate. These two loops used to live
672
+ * here and are now `hiddenSurfaceRoutes` — because `toPublicSiteSettings`
673
+ * needs the same answer to tell `src/proxy.ts` which browse routes to 404,
674
+ * and a sitemap that advertises a URL the edge 404s is a crawl error on a
675
+ * page we asked Google to fetch.
677
676
  */
678
- for (const t of ALL_LISTING_TYPES) {
679
- if (isListingSurfaceVisible(t, settings, counts)) {
680
- hiddenSurfacePaths.delete(String(pluginFor(t).browseRoute ?? ""));
681
- }
682
- }
677
+ const hiddenSurfacePaths = new Set(hiddenSurfaceRoutes(settings, counts));
683
678
  const keep = (entry) => {
684
679
  const path = entry.url.startsWith("http")
685
680
  ? new URL(entry.url).pathname
@@ -1,3 +1,4 @@
1
+ import { hiddenSurfaceRoutes } from "../../../shared/listing-types/hidden-surfaces.js";
1
2
  /**
2
3
  * Document fields with a proven `useSiteSettings()` reader. Every entry below
3
4
  * was traced to a call site; anything without one belongs in the private list,
@@ -197,6 +198,15 @@ export function toPublicSiteSettings(doc, opts) {
197
198
  * would take the entire catalogue off the navbar.
198
199
  */
199
200
  surfaceCounts: opts?.surfaceCounts,
201
+ /*
202
+ * Derived from the two fields above it — the admin flags on `doc` and the
203
+ * measured counts in `opts`. Computed here rather than in the proxy because
204
+ * the proxy runs on the EDGE: `hiddenSurfaceRoutes` reaches the listing-type
205
+ * registry, and pulling that into the middleware bundle to re-derive an
206
+ * answer the server already holds would be the wrong trade. The edge gets a
207
+ * list of strings to match, exactly like `disabledRoutes`.
208
+ */
209
+ hiddenSurfacePaths: hiddenSurfaceRoutes(doc, opts?.surfaceCounts),
200
210
  // D9 — undefined reads as OFF, never a default-true `??`.
201
211
  chat: { enabled: doc.chat?.enabled === true },
202
212
  /*
@@ -18,6 +18,23 @@ export declare const onPrizeDrawPaymentConfirmed: import("./types").DocumentTrig
18
18
  export declare const prizeDrawSoldOutReveal: import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>;
19
19
  export declare const onReviewWrite: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
20
20
  export declare const onCategoryWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>;
21
+ /**
22
+ * 🛑 The ONE trigger that invalidates the whole page cache.
23
+ *
24
+ * `documentWritten` and not `documentUpdated`: a settings document that is
25
+ * CREATED on first access (`getSingleton` writes defaults when it is missing)
26
+ * changes the site exactly as much as an edit does.
27
+ *
28
+ * `siteSettings` is a singleton, so the `{settingsId}` wildcard matches one
29
+ * document in practice — it is a wildcard because a trigger path must be, and
30
+ * the handler reads the id from it rather than hardcoding `"global"`.
31
+ *
32
+ * See the handler for why this carries no field-level diff while
33
+ * `onProductWrite` must, and for the measurement that prompted it: a listing
34
+ * type switched off in Firestore while its browse page kept serving a cached
35
+ * 200 because nothing marked it stale.
36
+ */
37
+ export declare const onSiteSettingsWrite: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
21
38
  export declare const onStoreWrite: import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>;
22
39
  export declare const onSupportTicketCreate: import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>;
23
40
  export declare const onSupportTicketUpdate: import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>;
@@ -54,7 +71,7 @@ export declare const FIRESTORE_TRIGGER_FUNCTIONS: readonly [import("./types").Do
54
71
  [x: string]: import("../../..").JsonValue;
55
72
  }, {
56
73
  [x: string]: import("../../..").JsonValue;
57
- }>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
74
+ }>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot, import("../jobs/core/onPrizeDrawPaymentConfirmed").PrizeDrawOrderSnapshot>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot, import("../jobs/core/prizeDrawSoldOutReveal").PrizeDrawStockSnapshot>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").CategoryDoc, import("../jobs").CategoryDoc>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<import("../jobs").StoreDoc, import("../jobs").StoreDoc>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<null, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<Record<string, import("../../..").JsonValue>, Record<string, import("../../..").JsonValue>>, import("./types").DocumentTriggerFunctionDefinition<{
58
75
  [x: string]: import("../../..").JsonValue;
59
76
  }, {
60
77
  [x: string]: import("../../..").JsonValue;
@@ -6,7 +6,7 @@
6
6
  * The path patterns mirror the original `functions/src/index.ts` declarations
7
7
  * to preserve identical deploy behavior.
8
8
  */
9
- import { onCategoryWriteHandler, onOrderCreateHandler, onOrderStatusChangeHandler, onProductStockChangeHandler, onProductWriteHandler, onReviewWriteHandler, onScamReportCreateHandler, onScamReportUpdateHandler, onStoreWriteHandler, onSupportTicketCreateHandler, onSupportTicketUpdateHandler, onUserBanChangeHandler, onShipmentItemWriteHandler, onShipmentLotWriteHandler, onShipmentHeaderWriteHandler, onShipmentDeletedHandler, onCatalogueSubmittedForApprovalHandler, onJobCreatedHandler, onPrizeDrawPaymentConfirmedHandler, prizeDrawSoldOutRevealHandler, } from "../jobs/handlers/index.js";
9
+ import { onCategoryWriteHandler, onOrderCreateHandler, onOrderStatusChangeHandler, onProductStockChangeHandler, onProductWriteHandler, onReviewWriteHandler, onScamReportCreateHandler, onScamReportUpdateHandler, onSiteSettingsWriteHandler, onStoreWriteHandler, onSupportTicketCreateHandler, onSupportTicketUpdateHandler, onUserBanChangeHandler, onShipmentItemWriteHandler, onShipmentLotWriteHandler, onShipmentHeaderWriteHandler, onShipmentDeletedHandler, onCatalogueSubmittedForApprovalHandler, onJobCreatedHandler, onPrizeDrawPaymentConfirmedHandler, prizeDrawSoldOutRevealHandler, } from "../jobs/handlers/index.js";
10
10
  import { defineFunction } from "./define.js";
11
11
  const REGION = "asia-south1";
12
12
  export const onOrderCreate = defineFunction({
@@ -65,6 +65,29 @@ export const onCategoryWrite = defineFunction({
65
65
  handler: onCategoryWriteHandler,
66
66
  options: { region: REGION },
67
67
  });
68
+ /**
69
+ * 🛑 The ONE trigger that invalidates the whole page cache.
70
+ *
71
+ * `documentWritten` and not `documentUpdated`: a settings document that is
72
+ * CREATED on first access (`getSingleton` writes defaults when it is missing)
73
+ * changes the site exactly as much as an edit does.
74
+ *
75
+ * `siteSettings` is a singleton, so the `{settingsId}` wildcard matches one
76
+ * document in practice — it is a wildcard because a trigger path must be, and
77
+ * the handler reads the id from it rather than hardcoding `"global"`.
78
+ *
79
+ * See the handler for why this carries no field-level diff while
80
+ * `onProductWrite` must, and for the measurement that prompted it: a listing
81
+ * type switched off in Firestore while its browse page kept serving a cached
82
+ * 200 because nothing marked it stale.
83
+ */
84
+ export const onSiteSettingsWrite = defineFunction({
85
+ name: "onSiteSettingsWrite",
86
+ description: "Invalidate the whole page cache when site settings change.",
87
+ trigger: { kind: "documentWritten", pathPattern: "siteSettings/{settingsId}" },
88
+ handler: onSiteSettingsWriteHandler,
89
+ options: { region: REGION },
90
+ });
68
91
  export const onStoreWrite = defineFunction({
69
92
  name: "onStoreWrite",
70
93
  description: "Side-effects on store writes (status mirror, owner uid index).",
@@ -165,6 +188,7 @@ export const FIRESTORE_TRIGGER_FUNCTIONS = [
165
188
  prizeDrawSoldOutReveal,
166
189
  onReviewWrite,
167
190
  onCategoryWrite,
191
+ onSiteSettingsWrite,
168
192
  onStoreWrite,
169
193
  onSupportTicketCreate,
170
194
  onSupportTicketUpdate,
@@ -45,6 +45,7 @@ export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility";
45
45
  export { onCategoryWriteHandler } from "./onCategoryWrite";
46
46
  export { onProductWriteHandler } from "./onProductWrite";
47
47
  export { onStoreWriteHandler } from "./onStoreWrite";
48
+ export { onSiteSettingsWriteHandler } from "./onSiteSettingsWrite";
48
49
  export { adminAnalyticsHandler, type AdminAnalyticsResult } from "./adminAnalytics";
49
50
  export { countersReconcileNowHandler, type CountersReconcileNowResult, } from "./countersReconcileNow";
50
51
  export { storeAnalyticsHandler, type StoreAnalyticsInput, type StoreAnalyticsResult, } from "./storeAnalytics";
@@ -45,6 +45,7 @@ export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility.js";
45
45
  export { onCategoryWriteHandler } from "./onCategoryWrite.js";
46
46
  export { onProductWriteHandler } from "./onProductWrite.js";
47
47
  export { onStoreWriteHandler } from "./onStoreWrite.js";
48
+ export { onSiteSettingsWriteHandler } from "./onSiteSettingsWrite.js";
48
49
  export { adminAnalyticsHandler } from "./adminAnalytics.js";
49
50
  export { countersReconcileNowHandler, } from "./countersReconcileNow.js";
50
51
  export { storeAnalyticsHandler, } from "./storeAnalytics.js";
@@ -0,0 +1,53 @@
1
+ import type { FirestoreTriggerHandler } from "../runtime/types";
2
+ import type { JsonValue } from "@mohasinac/appkit";
3
+ /**
4
+ * A site-settings write invalidates the whole page cache.
5
+ *
6
+ * ## Why this trigger had to exist
7
+ *
8
+ * `siteSettings/global` decides the navbar, the footer, the theme tokens, the
9
+ * guest price gate, every listing-type kill switch and `hiddenSurfacePaths` —
10
+ * i.e. something on every page of the site. Nothing observed writes to it.
11
+ *
12
+ * Measured against production 2026-10-27, which is what prompted this: with
13
+ * `listings.listingTypes.auction` set to `false` in Firestore, `/auctions` kept
14
+ * answering **200** from the CDN with `Age` climbing past its own 60-second
15
+ * `revalidate` window, because no writer ever marked the page stale. The flag
16
+ * was correct and invisible. `revalidateTargetsFor("siteSettings")` returned
17
+ * `["/"]` — the homepage alone — so even the one caller that existed could not
18
+ * have fixed it.
19
+ *
20
+ * ## 🛑 Performs NO Firestore write, by construction
21
+ *
22
+ * Root Cause #92 is this project's record of a trigger writing back to the
23
+ * collection it watched, producing 1.02M invocations/day and taking production
24
+ * down. `notifyRevalidate` performs exactly one `fetch` and touches no
25
+ * document, so this cannot re-trigger itself. If it ever needs to persist
26
+ * anything, it needs a different trigger path — not a write.
27
+ *
28
+ * ## 🛑 No field-level diff, deliberately — and that is the opposite of
29
+ * `onProductWrite`
30
+ *
31
+ * `onProductWrite` guards on `productCacheFieldsChanged` because products are
32
+ * written at MACHINE rate: every checkout stock decrement and every bid fires
33
+ * it, so an unguarded full invalidation there is the ISR-write amplification
34
+ * that got this project suspended with HTTP 402 (Root Cause #104).
35
+ *
36
+ * `siteSettings` is the opposite case. It is written by `PUT /api/admin/site`
37
+ * and two operator scripts and by nothing else — every scheduled job that names
38
+ * it only READS it (measured 2026-10-27). So this fires when a human saves
39
+ * settings, which is rare enough that a diff would add a maintained field list
40
+ * to protect against nothing. A settings save that changed only `updatedAt` is
41
+ * not worth a list that goes stale the next time a group is added.
42
+ *
43
+ * 🛑 If a job is ever given a reason to WRITE `siteSettings`, this guard has to
44
+ * come back before that ships — the amplification shape returns the moment the
45
+ * write rate stops being human.
46
+ *
47
+ * ## Best-effort
48
+ *
49
+ * `notifyRevalidate` swallows and logs every failure. A missed invalidation
50
+ * leaves a stale page until its TTL rolls; failing here would achieve nothing
51
+ * because the settings document is already saved.
52
+ */
53
+ export declare const onSiteSettingsWriteHandler: FirestoreTriggerHandler<Record<string, JsonValue>, Record<string, JsonValue>>;
@@ -0,0 +1,63 @@
1
+ import { notifyRevalidate } from "../core/revalidateNotify.js";
2
+ /**
3
+ * A site-settings write invalidates the whole page cache.
4
+ *
5
+ * ## Why this trigger had to exist
6
+ *
7
+ * `siteSettings/global` decides the navbar, the footer, the theme tokens, the
8
+ * guest price gate, every listing-type kill switch and `hiddenSurfacePaths` —
9
+ * i.e. something on every page of the site. Nothing observed writes to it.
10
+ *
11
+ * Measured against production 2026-10-27, which is what prompted this: with
12
+ * `listings.listingTypes.auction` set to `false` in Firestore, `/auctions` kept
13
+ * answering **200** from the CDN with `Age` climbing past its own 60-second
14
+ * `revalidate` window, because no writer ever marked the page stale. The flag
15
+ * was correct and invisible. `revalidateTargetsFor("siteSettings")` returned
16
+ * `["/"]` — the homepage alone — so even the one caller that existed could not
17
+ * have fixed it.
18
+ *
19
+ * ## 🛑 Performs NO Firestore write, by construction
20
+ *
21
+ * Root Cause #92 is this project's record of a trigger writing back to the
22
+ * collection it watched, producing 1.02M invocations/day and taking production
23
+ * down. `notifyRevalidate` performs exactly one `fetch` and touches no
24
+ * document, so this cannot re-trigger itself. If it ever needs to persist
25
+ * anything, it needs a different trigger path — not a write.
26
+ *
27
+ * ## 🛑 No field-level diff, deliberately — and that is the opposite of
28
+ * `onProductWrite`
29
+ *
30
+ * `onProductWrite` guards on `productCacheFieldsChanged` because products are
31
+ * written at MACHINE rate: every checkout stock decrement and every bid fires
32
+ * it, so an unguarded full invalidation there is the ISR-write amplification
33
+ * that got this project suspended with HTTP 402 (Root Cause #104).
34
+ *
35
+ * `siteSettings` is the opposite case. It is written by `PUT /api/admin/site`
36
+ * and two operator scripts and by nothing else — every scheduled job that names
37
+ * it only READS it (measured 2026-10-27). So this fires when a human saves
38
+ * settings, which is rare enough that a diff would add a maintained field list
39
+ * to protect against nothing. A settings save that changed only `updatedAt` is
40
+ * not worth a list that goes stale the next time a group is added.
41
+ *
42
+ * 🛑 If a job is ever given a reason to WRITE `siteSettings`, this guard has to
43
+ * come back before that ships — the amplification shape returns the moment the
44
+ * write rate stops being human.
45
+ *
46
+ * ## Best-effort
47
+ *
48
+ * `notifyRevalidate` swallows and logs every failure. A missed invalidation
49
+ * leaves a stale page until its TTL rolls; failing here would achieve nothing
50
+ * because the settings document is already saved.
51
+ */
52
+ export const onSiteSettingsWriteHandler = async (event, ctx) => {
53
+ await notifyRevalidate({
54
+ collection: "siteSettings",
55
+ /*
56
+ * The singleton id, from the path params rather than hardcoded "global".
57
+ * The trigger pattern is `siteSettings/{settingsId}` and a second
58
+ * document in that collection would otherwise invalidate under the wrong
59
+ * id — and the endpoint logs this value.
60
+ */
61
+ id: event.params.settingsId,
62
+ }, ctx);
63
+ };
@@ -134,4 +134,26 @@ export interface PublicSiteSettings {
134
134
  listingTypes?: Record<string, number>;
135
135
  surfaces?: Record<string, number>;
136
136
  };
137
+ /**
138
+ * Browse routes that must answer 404 — derived from `listings.listingTypes`
139
+ * and `surfaceCounts` by `hiddenSurfaceRoutes`, not a stored field.
140
+ *
141
+ * 🛑 THIS EXISTS BECAUSE A PAGE-LEVEL `notFound()` CANNOT SET THE STATUS.
142
+ * Every one of these pages already calls `guardListingSurface`, and measured
143
+ * against production on 2026-10-27 all seven rendered the "404 — Page not
144
+ * found" body with HTTP **200**: the layout shell has already streamed by the
145
+ * time the guard throws, so the status is no longer mutable. `/reviews` was
146
+ * the one real 404 on the site, and only because it was in `disabledRoutes`
147
+ * and therefore caught at the edge.
148
+ *
149
+ * So the edge is the only place that can answer correctly, and `src/proxy.ts`
150
+ * consumes this exactly as it consumes `disabledRoutes` — same rewrite, same
151
+ * cached fetch, no extra request. The page guards stay as defence in depth
152
+ * (they are what hides the content if this payload never arrives).
153
+ *
154
+ * 🛑 Always emitted, defaulted to `[]`, for the reason spelled out on
155
+ * `disabledRoutes`: the proxy fails OPEN, so "nothing hidden" and "the field
156
+ * never arrived" are otherwise indistinguishable on the wire.
157
+ */
158
+ hiddenSurfacePaths: string[];
137
159
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Which public BROWSE ROUTES must not be served, given the admin flags and the
3
+ * measured counts.
4
+ *
5
+ * ## Why this is one function and not two copies
6
+ *
7
+ * Two places need this answer and they must never disagree:
8
+ *
9
+ * · `buildSitemap` — decides which browse URLs are advertised.
10
+ * · `toPublicSiteSettings` → `src/proxy.ts` — decides which browse URLs 404.
11
+ *
12
+ * A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
13
+ * is a crawl error on a page we told Google to fetch, and the reverse serves a
14
+ * surface we have decided is closed. They were two hand-written copies of the
15
+ * same two loops when this was extracted.
16
+ *
17
+ * ## 🛑 EXACT PATHS, never prefixes
18
+ *
19
+ * `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
20
+ * so a prefix rule would delist — or 404 — the entire standard catalogue the
21
+ * day `spares` emptied. An empty type contributes no detail URLs anyway, since
22
+ * its own fetcher returns none, so the exact-path rule loses nothing real.
23
+ *
24
+ * ## 🛑 A SHARED route survives while ANY of its types is visible
25
+ *
26
+ * `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
27
+ * others. Without the second pass, hiding `stickers` alone would close a page
28
+ * `art` still fills — and hiding `spares` alone would close `/products`.
29
+ *
30
+ * @tag domain:products
31
+ * @tag layer:shared
32
+ * @tag access:isomorphic
33
+ * @tag sideEffects:none
34
+ */
35
+ import { type SurfaceCountSnapshot } from "./feature-flags";
36
+ /** The snapshot shape `isListingSurfaceVisible` reads its admin flags from. */
37
+ interface SettingsSnapshot {
38
+ listings?: {
39
+ listingTypes?: Partial<Record<string, boolean>>;
40
+ };
41
+ }
42
+ export declare function hiddenSurfaceRoutes(settings: SettingsSnapshot | null | undefined, counts: SurfaceCountSnapshot | null | undefined): string[];
43
+ export {};
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Which public BROWSE ROUTES must not be served, given the admin flags and the
3
+ * measured counts.
4
+ *
5
+ * ## Why this is one function and not two copies
6
+ *
7
+ * Two places need this answer and they must never disagree:
8
+ *
9
+ * · `buildSitemap` — decides which browse URLs are advertised.
10
+ * · `toPublicSiteSettings` → `src/proxy.ts` — decides which browse URLs 404.
11
+ *
12
+ * A disagreement is not cosmetic: the sitemap advertising a URL the edge 404s
13
+ * is a crawl error on a page we told Google to fetch, and the reverse serves a
14
+ * surface we have decided is closed. They were two hand-written copies of the
15
+ * same two loops when this was extracted.
16
+ *
17
+ * ## 🛑 EXACT PATHS, never prefixes
18
+ *
19
+ * `art`, `stickers`, `spares` and `standard` all detail at `/products/{slug}`,
20
+ * so a prefix rule would delist — or 404 — the entire standard catalogue the
21
+ * day `spares` emptied. An empty type contributes no detail URLs anyway, since
22
+ * its own fetcher returns none, so the exact-path rule loses nothing real.
23
+ *
24
+ * ## 🛑 A SHARED route survives while ANY of its types is visible
25
+ *
26
+ * `/art` spans `art` and `stickers`; `/products` spans `standard` plus several
27
+ * others. Without the second pass, hiding `stickers` alone would close a page
28
+ * `art` still fills — and hiding `spares` alone would close `/products`.
29
+ *
30
+ * @tag domain:products
31
+ * @tag layer:shared
32
+ * @tag access:isomorphic
33
+ * @tag sideEffects:none
34
+ */
35
+ import { ALL_LISTING_TYPES, isListingSurfaceVisible, } from "./feature-flags.js";
36
+ import { pluginFor } from "./_registry.js";
37
+ export function hiddenSurfaceRoutes(settings, counts) {
38
+ const hidden = new Set();
39
+ const routeOf = (type) => String(pluginFor(type).browseRoute ?? "");
40
+ for (const type of ALL_LISTING_TYPES) {
41
+ if (isListingSurfaceVisible(type, settings, counts))
42
+ continue;
43
+ const route = routeOf(type);
44
+ if (route)
45
+ hidden.add(route);
46
+ }
47
+ /*
48
+ * Second pass, deliberately not folded into the first: a route is only
49
+ * hidden once EVERY type that browses there is hidden, and the first pass
50
+ * cannot know that until it has seen them all.
51
+ */
52
+ for (const type of ALL_LISTING_TYPES) {
53
+ if (isListingSurfaceVisible(type, settings, counts))
54
+ hidden.delete(routeOf(type));
55
+ }
56
+ return [...hidden];
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.67.0",
3
+ "version": "4.68.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"