@faststore/api 4.7.1-dev.0 → 4.7.1-dev.1

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,4 +1,4 @@
1
- import type { GraphqlResolver } from '..'
1
+ import type { GraphqlContext, GraphqlResolver } from '..'
2
2
  import type { Brand } from '../clients/commerce/types/Brand'
3
3
  import type { CategoryTree } from '../clients/commerce/types/CategoryTree'
4
4
  import type { CollectionPageType } from '../clients/commerce/types/Portal'
@@ -39,6 +39,41 @@ export type LegacyStoreCollectionRoot =
39
39
  */
40
40
  export type Root = ByLinkIdRoot | LegacyStoreCollectionRoot
41
41
 
42
+ /**
43
+ * Lowercases a catalog slug so one spelling is announced per locale.
44
+ *
45
+ * Merchants register linkIds in whatever casing they typed ("Elettronica",
46
+ * "Eletronicos"), and by-linkid resolves any casing, so without this every
47
+ * variant would be a legitimate URL advertising itself as canonical. Accents
48
+ * are preserved: they are part of the registered slug, not a casing artifact.
49
+ */
50
+ const canonicalizeSlug = (slug: string): string => slug.toLowerCase()
51
+
52
+ /**
53
+ * Loads the by-linkid entity for every level of a collection slug, so
54
+ * "vestuario/camisetas" resolves both "vestuario" and "vestuario/camisetas".
55
+ * The collectionLoader cache means a segment already fetched by another
56
+ * resolver of the same request costs nothing here.
57
+ */
58
+ const loadSegmentEntities = (
59
+ ctx: GraphqlContext,
60
+ slug: string
61
+ ): Promise<ByLinkIdRoot[]> => {
62
+ const segments = slug.split('/').filter(Boolean)
63
+ const {
64
+ loaders: { collectionLoader },
65
+ } = ctx
66
+
67
+ return Promise.all(
68
+ segments.map((_, index) =>
69
+ collectionLoader.load({
70
+ slug: segments.slice(0, index + 1).join('/'),
71
+ locale: getCatalogLocale(ctx),
72
+ })
73
+ )
74
+ )
75
+ }
76
+
42
77
  const slugifyRoot = (root: ByLinkIdRoot): string => {
43
78
  if (isCategory(root)) {
44
79
  // root.slug is the full accumulated input slug (e.g. "vestuario/camisetas"),
@@ -86,24 +121,13 @@ export const StoreCollection: Record<string, GraphqlResolver<ByLinkIdRoot>> = {
86
121
  }
87
122
 
88
123
  // For categories, IS expects the canonical (default-locale) slug in selectedFacets
89
- // regardless of which locale the current request uses. The by-linkid API echoes the
90
- // queried slug in `linkId`, so we resolve canonical via availableLinkIds[defaultLocale].
124
+ // regardless of which locale the current request uses. `linkId` carries the slug
125
+ // registered for the *requested* locale, so it is the wrong one here whenever the
126
+ // shopper is browsing anything but the default locale; availableLinkIds is keyed by
127
+ // locale and gives us the default-locale slug directly.
91
128
  const { defaultLocale } = getLocalizationConfig(ctx)
92
129
 
93
- const segments = slug.split('/').filter(Boolean)
94
- const segmentSlugs = segments.map((_, i) =>
95
- segments.slice(0, i + 1).join('/')
96
- )
97
-
98
- const {
99
- loaders: { collectionLoader },
100
- } = ctx
101
-
102
- const entities = await Promise.all(
103
- segmentSlugs.map((s) =>
104
- collectionLoader.load({ slug: s, locale: getCatalogLocale(ctx) })
105
- )
106
- )
130
+ const entities = await loadSegmentEntities(ctx, slug)
107
131
 
108
132
  return {
109
133
  selectedFacets: entities.map((entity, index) => ({
@@ -115,27 +139,7 @@ export const StoreCollection: Record<string, GraphqlResolver<ByLinkIdRoot>> = {
115
139
  }
116
140
  },
117
141
  breadcrumbList: async (root, _, ctx) => {
118
- const {
119
- loaders: { collectionLoader },
120
- } = ctx
121
-
122
- const slug = slugifyRoot(root)
123
-
124
- /**
125
- * Split slug into segments so each breadcrumb level gets its own
126
- * by-linkid result. For "vestuario/camisetas" this produces two loader
127
- * calls: one for "vestuario" and one for "vestuario/camisetas".
128
- */
129
- const segments = slug.split('/').filter(Boolean)
130
- const slugs = segments.map((_, index) =>
131
- segments.slice(0, index + 1).join('/')
132
- )
133
-
134
- const collections = await Promise.all(
135
- slugs.map((s) =>
136
- collectionLoader.load({ slug: s, locale: getCatalogLocale(ctx) })
137
- )
138
- )
142
+ const collections = await loadSegmentEntities(ctx, slugifyRoot(root))
139
143
 
140
144
  return {
141
145
  itemListElement: collections.map((collection, index) => ({
@@ -156,31 +160,14 @@ export const StoreCollection: Record<string, GraphqlResolver<ByLinkIdRoot>> = {
156
160
 
157
161
  if (configuredLocales.length === 0) return null
158
162
 
159
- const currentLocale = ctx.storage.locale
160
163
  const slug = slugifyRoot(root)
161
- const segments = slug.split('/').filter(Boolean)
162
164
 
163
- if (segments.length === 0) return null
164
-
165
- const {
166
- loaders: { collectionLoader },
167
- } = ctx
168
-
169
- // Build per-level slug paths: ["vestuario", "vestuario/camisetas"].
170
- // The collectionLoader DataLoader cache means any segment already fetched
171
- // by breadcrumbList or meta costs nothing here.
172
- const segmentSlugs = segments.map((_, i) =>
173
- segments.slice(0, i + 1).join('/')
174
- )
165
+ if (slug.split('/').filter(Boolean).length === 0) return null
175
166
 
176
167
  let entities: ByLinkIdRoot[]
177
168
 
178
169
  try {
179
- entities = await Promise.all(
180
- segmentSlugs.map((s) =>
181
- collectionLoader.load({ slug: s, locale: getCatalogLocale(ctx) })
182
- )
183
- )
170
+ entities = await loadSegmentEntities(ctx, slug)
184
171
  } catch (err) {
185
172
  console.warn('[otherLocales] failed to load collection entities:', err)
186
173
 
@@ -189,16 +176,14 @@ export const StoreCollection: Record<string, GraphqlResolver<ByLinkIdRoot>> = {
189
176
 
190
177
  return configuredLocales
191
178
  .map((configuredLocale) => {
192
- if (configuredLocale === currentLocale) {
193
- // The input slug is already the localized path for the current locale.
194
- return { locale: configuredLocale, slug }
195
- }
196
-
197
- // Build the full path by joining each segment's localized linkId from
198
- // availableLinkIds. For the default locale, the canonical slug is also
199
- // present in availableLinkIds.
200
- // If any segment is missing an entry for this locale, omit the whole URL
201
- // to keep the hreflang cluster symmetric.
179
+ // Every locale, including the one being rendered, is read from
180
+ // availableLinkIds, which holds registered translations only. by-linkid
181
+ // resolves an untranslated segment by falling back to a slug that
182
+ // exists in another locale, so trusting its `linkId` here would
183
+ // advertise an alternate the target locale never registered and break
184
+ // the reciprocity hreflang requires. The page still declares itself
185
+ // canonical through `canonicalSlug`, which is free to fall back
186
+ // precisely because it is not an hreflang annotation.
202
187
  const parts: string[] = []
203
188
 
204
189
  for (const entity of entities) {
@@ -210,9 +195,37 @@ export const StoreCollection: Record<string, GraphqlResolver<ByLinkIdRoot>> = {
210
195
  }
211
196
 
212
197
  return parts.length > 0
213
- ? { locale: configuredLocale, slug: parts.join('/') }
198
+ ? {
199
+ locale: configuredLocale,
200
+ slug: canonicalizeSlug(parts.join('/')),
201
+ }
214
202
  : null
215
203
  })
216
204
  .filter((e): e is { locale: string; slug: string } => e !== null)
217
205
  },
206
+
207
+ canonicalSlug: async (root, _, ctx) => {
208
+ const slug = slugifyRoot(root)
209
+
210
+ if (slug.split('/').filter(Boolean).length === 0) return null
211
+
212
+ let entities: ByLinkIdRoot[]
213
+
214
+ try {
215
+ entities = await loadSegmentEntities(ctx, slug)
216
+ } catch (err) {
217
+ console.warn('[canonicalSlug] failed to load collection entities:', err)
218
+
219
+ return null
220
+ }
221
+
222
+ const parts = entities.map((entity) => entity.linkId)
223
+
224
+ // A segment with no linkId leaves nothing better than the visited slug to
225
+ // announce, so the page keeps describing itself by the path it was reached
226
+ // through.
227
+ return parts.every((part): part is string => Boolean(part))
228
+ ? canonicalizeSlug(parts.join('/'))
229
+ : canonicalizeSlug(slug)
230
+ },
218
231
  }
@@ -95,9 +95,20 @@ type StoreCollection {
95
95
  type: StoreCollectionType!
96
96
  """
97
97
  Localized versions of this collection for all available locales.
98
- Only populated when localization is enabled.
98
+ Only announces slugs the catalog has registered for each locale, so it is safe
99
+ to build hreflang annotations from. Only populated when localization is
100
+ enabled.
99
101
  """
100
102
  otherLocales: [StoreCollectionLocale!]
103
+ """
104
+ Slug the catalog resolves for the requested locale, lowercased.
105
+ Meant for the page's canonical URL: `slug` echoes the path the shopper
106
+ visited, and by-linkid matches case-insensitively, so without this every
107
+ casing variant would claim to be canonical. Unlike `otherLocales`, it may fall
108
+ back to a slug registered for another locale, so it must never be used to
109
+ build hreflang annotations.
110
+ """
111
+ canonicalSlug: String
101
112
  }
102
113
 
103
114
  """