orionplace-svelte 0.6.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-svelte",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "SvelteKit 2 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,18 @@ export declare function marketplacePublicOrigin(
37
37
  tenantDomain?: string,
38
38
  ): string;
39
39
 
40
+ export declare function noindexPathPrefixes(value?: string): string[];
41
+
42
+ export declare function marketplaceNoindexPath(
43
+ pathname: string,
44
+ value?: string,
45
+ ): boolean;
46
+
47
+ export declare function sitemapIndexedPages(
48
+ pages: MarketplaceSitemapPage[],
49
+ value?: string,
50
+ ): MarketplaceSitemapPage[];
51
+
40
52
  export declare function marketplaceRobots(origin: string): string;
41
53
 
42
54
  export declare function marketplaceRobotsResponse(
@@ -329,6 +329,7 @@ export async function forwardMarketplace(request, configuration = {}) {
329
329
  // node_modules/.cache/orionplace/sitemaps/<domain>.json (ORIONPLACE_CACHE_DIR
330
330
  // moves it). "orionplace-<framework> sitemap rebuild" rebuilds that file at
331
331
  // once; the running storefront serves the new file without a restart.
332
+ // Paths in ORIONPLACE_NOINDEX_PATHS are left out when the sitemap is served.
332
333
 
333
334
  export const SITEMAP_LIFETIME = 60 * 60 * 1000;
334
335
  const sitemapPageSize = 100;
@@ -353,6 +354,34 @@ export function marketplacePublicOrigin(request, tenantDomain = '') {
353
354
  return local || !tenantDomain ? url.origin : `https://${tenantDomain}`;
354
355
  }
355
356
 
357
+ /**
358
+ * Pages kept out of search engines: ORIONPLACE_NOINDEX_PATHS lists path
359
+ * prefixes separated by spaces or commas, for example "/catalog /category
360
+ * /product" on a demo or staging storefront. Those pages answer with
361
+ * `X-Robots-Tag: noindex, follow` and the sitemap leaves them out. Empty
362
+ * (the default) keeps every public page indexable.
363
+ */
364
+ export function noindexPathPrefixes(value = environment.ORIONPLACE_NOINDEX_PATHS) {
365
+ return String(value ?? '')
366
+ .split(/[\s,]+/)
367
+ .map((item) => item.trim().replace(/\/+$/, ''))
368
+ .filter((item) => /^\/[A-Za-z0-9._~\/-]+$/.test(item));
369
+ }
370
+
371
+ /** Whether a page path is kept out of search engines (see noindexPathPrefixes). */
372
+ export function marketplaceNoindexPath(pathname, value = environment.ORIONPLACE_NOINDEX_PATHS) {
373
+ const path = String(pathname ?? '');
374
+ return noindexPathPrefixes(value)
375
+ .some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
376
+ }
377
+
378
+ /** The sitemap pages search engines may index. */
379
+ export function sitemapIndexedPages(pages, value = environment.ORIONPLACE_NOINDEX_PATHS) {
380
+ return noindexPathPrefixes(value).length
381
+ ? pages.filter((page) => !marketplaceNoindexPath(page.path, value))
382
+ : pages;
383
+ }
384
+
356
385
  /** @param {string} origin */
357
386
  export function marketplaceRobots(origin) {
358
387
  return `User-agent: *\nDisallow: /api/\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`;
@@ -618,7 +647,7 @@ async function buildMarketplaceSitemap(tenant) {
618
647
  /** How many <url> entries a built sitemap has. */
619
648
  export function sitemapUrlCount(sitemap) {
620
649
  let count = 0;
621
- for (const page of sitemap.pages) {
650
+ for (const page of sitemapIndexedPages(sitemap.pages)) {
622
651
  const own = page.locales
623
652
  ? sitemap.locales.filter((code) => page.locales.includes(code))
624
653
  : sitemap.locales;
@@ -776,7 +805,7 @@ export async function marketplaceSitemapResponse(request, configuration = {}) {
776
805
  }
777
806
  }
778
807
  const origin = marketplacePublicOrigin(request, tenant.domain);
779
- return new Response(marketplaceSitemapXml(origin, sitemap.pages, sitemap.locales), {
808
+ return new Response(marketplaceSitemapXml(origin, sitemapIndexedPages(sitemap.pages), sitemap.locales), {
780
809
  headers: {
781
810
  'Content-Type': 'application/xml; charset=utf-8',
782
811
  'Cache-Control': 'public, max-age=3600',
@@ -397,6 +397,12 @@ npx orionplace-svelte sitemap rebuild shop.example.com # one domain
397
397
  npx orionplace-svelte sitemap status # when each was built
398
398
  \`\`\`
399
399
 
400
+ To keep a demo or staging storefront's shop pages out of search engines, set
401
+ \`ORIONPLACE_NOINDEX_PATHS\` in \`.env\` to path prefixes separated by spaces,
402
+ for example \`ORIONPLACE_NOINDEX_PATHS="/catalog /category /product"\`. Those
403
+ pages answer with \`X-Robots-Tag: noindex, follow\` and the sitemap leaves them
404
+ out; the journal and other pages stay indexable. Leave it empty on a live shop.
405
+
400
406
  This setup does not add analytics.
401
407
 
402
408
  ## Android app
@@ -1,4 +1,6 @@
1
1
  import type { Handle } from '@sveltejs/kit';
2
+ import { env } from '$env/dynamic/private';
3
+ import { marketplaceNoindexPath } from '@orionplace-runtime/marketplace.mjs';
2
4
 
3
5
  function escapeAttribute(value: string) {
4
6
  return value
@@ -30,6 +32,12 @@ export const handle: Handle = async ({ event, resolve }) => {
30
32
  response.headers.set('Cache-Control', 'private, no-store');
31
33
  response.headers.set('Vary', 'Host, X-Forwarded-Host, X-Front-Domain');
32
34
  }
35
+ // Pages under ORIONPLACE_NOINDEX_PATHS stay out of search engines.
36
+ if (
37
+ marketplaceNoindexPath(event.url.pathname, env.ORIONPLACE_NOINDEX_PATHS)
38
+ ) {
39
+ response.headers.set('X-Robots-Tag', 'noindex, follow');
40
+ }
33
41
  // A page rendered while Application Load failed is the 503 error page.
34
42
  if (
35
43
  event.locals.orionplaceUnavailable &&
@@ -171,7 +171,7 @@
171
171
  onclick={() => openBranch(item.id)}
172
172
  class={['op-feature-button px-3!']}
173
173
  >
174
- {labels.value.categories} →
174
+ {labels.value.categories} <span aria-hidden="true">→</span>
175
175
  </button>
176
176
  {/if}
177
177
  <a
@@ -184,7 +184,7 @@
184
184
  'op-feature-link op-feature-button op-feature-primary px-3!',
185
185
  ]}
186
186
  >
187
- {labels.value.products} →
187
+ {labels.value.products} <span aria-hidden="true">→</span>
188
188
  </a>
189
189
  </li>
190
190
  {/each}
@@ -1,7 +1,14 @@
1
+ import { page as currentPage } from '$app/state';
2
+ import { browser } from '$app/environment';
1
3
  import { computed, toValue } from '@/lib/orionplace/reactivity.svelte';
2
4
  import type { MaybeRefOrGetter } from '@/lib/orionplace/reactivity.svelte';
3
- import { useSeoMeta } from '@/lib/orionplace/navigation.svelte';
4
- import { storefrontPageMetadata } from '@/lib/utils/orionplace/pageSeo';
5
+ import { useHead, useSeoMeta } from '@/lib/orionplace/navigation.svelte';
6
+ import {
7
+ storefrontAbsoluteUrl,
8
+ storefrontCanonicalUrl,
9
+ storefrontPageMetadata,
10
+ storefrontPublicOrigin,
11
+ } from '@/lib/utils/orionplace/pageSeo';
5
12
  import type { StorefrontSeoPage } from '@/lib/utils/orionplace/pageSeo';
6
13
 
7
14
  export function useOrionplacePageSeo(
@@ -15,24 +22,42 @@ export function useOrionplacePageSeo(
15
22
  image?: MaybeRefOrGetter<string | undefined>;
16
23
  } = {},
17
24
  ) {
18
- const metadata = computed(() =>
25
+ // The address the visitor used: the proxy's host during the server render.
26
+ const origin = browser
27
+ ? window.location.origin
28
+ : storefrontPublicOrigin(currentPage.url.host);
29
+ const metadata = computed(() =>
19
30
  storefrontPageMetadata(toValue(page), toValue(locale), {
20
31
  title: toValue(values.title),
21
32
  description: toValue(values.description),
22
33
  keywords: toValue(values.keywords),
23
34
  }),
24
35
  );
36
+ const canonical = computed(() =>
37
+ storefrontCanonicalUrl(
38
+ origin,
39
+ currentPage.url.pathname,
40
+ currentPage.url.search,
41
+ toValue(locale),
42
+ toValue(values.robots),
43
+ ),
44
+ );
25
45
 
26
46
  // Link previews use the same title and description as the page; the
27
- // image is the record's first picture when the page has one.
28
- const seo = {
47
+ // image is the record's first picture when the page has one, as a
48
+ // complete address.
49
+ const seo = {
29
50
  title: () => metadata.value.title,
30
51
  description: () => metadata.value.description,
31
52
  keywords: () => metadata.value.keywords,
32
53
  robots: () => toValue(values.robots),
33
54
  ogTitle: () => metadata.value.title,
34
55
  ogDescription: () => metadata.value.description,
35
- ogImage: () => toValue(values.image) || undefined,
56
+ ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
57
+ ogUrl: () => canonical.value,
36
58
  };
37
59
  useSeoMeta(seo);
60
+ useHead(() => ({
61
+ link: canonical.value ? [{ rel: 'canonical', href: canonical.value }] : [],
62
+ }));
38
63
  }
@@ -6,6 +6,10 @@ import {
6
6
  watch,
7
7
  } from '@/lib/orionplace/reactivity.svelte';
8
8
  import type { MaybeRefOrGetter } from '@/lib/orionplace/reactivity.svelte';
9
+ import { browser } from '$app/environment';
10
+ import { replaceState } from '$app/navigation';
11
+ import { page as currentPage } from '$app/state';
12
+ import { routeHref } from '@/lib/orionplace/navigation.svelte';
9
13
  import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
10
14
  import { useOrionplaceApi } from '@/lib/composables/useOrionplaceApi.svelte';
11
15
  import { useOrionplaceProgress } from '@/lib/composables/useOrionplaceProgress.svelte';
@@ -25,6 +29,12 @@ import type {
25
29
  } from '@/lib/interfaces/orionplace/products/IProduct';
26
30
  import type { IProductSearchFacets } from '@/lib/interfaces/orionplace/products/IProductSearchFacets';
27
31
  import type { OrionplaceFeatureApi } from '@/lib/composables/useOrionplaceFeature.svelte';
32
+ import {
33
+ productListLocationFromQuery,
34
+ productListLocationQuery,
35
+ sameProductListLocation,
36
+ } from '@/lib/utils/orionplace/productListLocation';
37
+ import type { ProductListLocation } from '@/lib/utils/orionplace/productListLocation';
28
38
 
29
39
  export type ProductSearchData = IProductSearchFacets & {
30
40
  products: { data: IProduct[]; rpag?: IProductPagination };
@@ -42,10 +52,28 @@ export type ProductSearchContext = ProductSearchScope & { categoryId?: number };
42
52
  */
43
53
  export interface ProductSearchSnapshot {
44
54
  scope: string;
55
+ // The page and filters it was loaded with; see productListLocation.
56
+ list?: string;
45
57
  productIds: number[];
46
58
  rpag?: IProductPagination;
47
59
  }
48
60
 
61
+ const firstListPage: ProductListLocation = { page: 1, filters: {} };
62
+
63
+ function listSignature(location: ProductListLocation) {
64
+ return JSON.stringify(productListLocationQuery({}, location));
65
+ }
66
+
67
+ // The browser reads the address bar: a shallow replaceState (below) does not
68
+ // update `page.url`.
69
+ function addressQuery(): Record<string, string> {
70
+ return Object.fromEntries(
71
+ browser
72
+ ? new URL(window.location.href).searchParams
73
+ : currentPage.url.searchParams,
74
+ );
75
+ }
76
+
49
77
  type OrionplaceStore = ReturnType<typeof useOrionplaceStore>;
50
78
 
51
79
  /**
@@ -57,6 +85,7 @@ export function productSearchBody(
57
85
  store: OrionplaceStore,
58
86
  scope: ProductSearchContext,
59
87
  page: number,
88
+ filters = store.searchFilters,
60
89
  ) {
61
90
  const category =
62
91
  scope.categoryId === undefined
@@ -72,7 +101,7 @@ export function productSearchBody(
72
101
 
73
102
  if (scope.categoryId !== undefined) {
74
103
  return category
75
- ? categorySearchRequest(category, store.searchFilters, page)
104
+ ? categorySearchRequest(category, filters, page)
76
105
  : undefined;
77
106
  }
78
107
 
@@ -80,7 +109,35 @@ export function productSearchBody(
80
109
  return undefined;
81
110
  }
82
111
 
83
- return scopedSearchRequest(scope, store.searchFilters, page);
112
+ return scopedSearchRequest(scope, filters, page);
113
+ }
114
+
115
+ /**
116
+ * A list opened from its URL (Back, a reload) on a later page or with
117
+ * filters gets only the filtered choices, or none after the first page.
118
+ * Without stored choices for the scope this also asks for the unfiltered
119
+ * first page, so the filter panel keeps every seller, price and option to
120
+ * choose from. The extra request is only for the panel: when it fails the
121
+ * panel shows the choices of the result.
122
+ */
123
+ async function preserveFullSearchFacets(
124
+ store: OrionplaceStore,
125
+ scope: ProductSearchContext,
126
+ page: number,
127
+ requestData: (body: Record<string, unknown>) => Promise<ProductSearchData>,
128
+ ) {
129
+ const needed = page > 1 || Object.keys(store.searchFilters).length > 0;
130
+ const body = needed && !store.searchFacets
131
+ ? productSearchBody(store, scope, 1, {})
132
+ : undefined;
133
+
134
+ if (!body) {
135
+ return;
136
+ }
137
+ const full = await requestData(body).catch(() => undefined);
138
+ if (full && !store.searchFacets) {
139
+ store.preserveSearchFacets(full);
140
+ }
84
141
  }
85
142
 
86
143
  /** Stores the returned products and keeps the scope's first filter choices. */
@@ -98,24 +155,34 @@ export function storeProductSearchPage(
98
155
  }
99
156
 
100
157
  /**
101
- * Loads the first result page during a server render, with the filters
102
- * currently in the store. Returns null when the scope cannot be searched.
158
+ * Loads a result page during a server render: the page and filters of the
159
+ * address (`location`, the first page by default), so a reload shows the
160
+ * list the visitor left. Returns null when the scope cannot be searched.
103
161
  */
104
162
  export async function loadProductSearchSnapshot(
105
163
  api: Pick<OrionplaceFeatureApi, 'request'>,
106
164
  store: OrionplaceStore,
107
165
  scope: ProductSearchContext,
166
+ location: ProductListLocation = firstListPage,
108
167
  ): Promise<ProductSearchSnapshot | null> {
109
- const body = productSearchBody(store, scope, 1);
168
+ store.setSearchFilters(location.filters);
169
+ const body = productSearchBody(store, scope, location.page);
110
170
 
111
171
  if (!body) {
112
172
  return null;
113
173
  }
114
174
 
115
- const data = await api.request<ProductSearchData>('products_search', body);
175
+ // Both requests run together; the full choices are kept first.
176
+ const [data] = await Promise.all([
177
+ api.request<ProductSearchData>('products_search', body),
178
+ preserveFullSearchFacets(store, scope, location.page, (full) =>
179
+ api.request<ProductSearchData>('products_search', full),
180
+ ),
181
+ ]);
116
182
 
117
183
  return {
118
184
  scope: JSON.stringify(scope),
185
+ list: listSignature(location),
119
186
  productIds: storeProductSearchPage(store, data),
120
187
  rpag: data.products.rpag,
121
188
  };
@@ -131,24 +198,40 @@ export function useOrionplaceProductSearch(
131
198
  scrollToTop?: boolean;
132
199
  // The first page from the server render; see useOrionplaceServerData.
133
200
  initial?: ProductSearchSnapshot | null;
201
+ // `false` keeps the page and filters out of the URL.
202
+ rememberInAddress?: boolean;
134
203
  } = {},
135
204
  ) {
136
205
  const store = useOrionplaceStore();
206
+ const remember = options.rememberInAddress !== false;
137
207
  const api = options.request ? undefined : useOrionplaceApi();
138
208
  const requestData =
139
209
  options.request ??
140
210
  ((body, signal) =>
141
211
  api!.request<ProductSearchData>('products_search', body, signal));
142
- // The server result is used only for the scope it was loaded for.
212
+ // The page and filters in the URL: Back from a product or a reload opens
213
+ // the list the visitor left.
214
+ function addressLocation(): ProductListLocation {
215
+ return remember ? productListLocationFromQuery(addressQuery()) : firstListPage;
216
+ }
217
+ const opening = addressLocation();
218
+ // The server result is used only for the scope and the page and filters
219
+ // it was loaded for.
143
220
  const initial =
144
- options.initial?.scope === JSON.stringify(toValue(context))
221
+ options.initial?.scope === JSON.stringify(toValue(context)) &&
222
+ (options.initial.list ?? listSignature(firstListPage)) ===
223
+ listSignature(opening)
145
224
  ? options.initial
146
225
  : undefined;
226
+ if (initial) {
227
+ // The filter panel shows the filters this result was loaded with.
228
+ store.setSearchFilters(opening.filters);
229
+ }
147
230
  const productIds = ref<number[]>(initial ? [...initial.productIds] : []);
148
231
  const pagination = ref<IProductPagination>(
149
232
  initial ? mergeCategoryPagination({}, initial.rpag) : {},
150
233
  );
151
- const page = ref(1);
234
+ const page = ref(initial ? opening.page : 1);
152
235
  const pending = ref(!initial);
153
236
  const failed = ref(false);
154
237
  const loaded = ref(Boolean(initial));
@@ -157,6 +240,28 @@ export function useOrionplaceProductSearch(
157
240
 
158
241
  useOrionplaceProgress(() => pending.value);
159
242
 
243
+ // Replaces the URL without a new load, so Back from the list still leaves
244
+ // it in one step.
245
+ function rememberLocation() {
246
+ const next = { page: page.value, filters: store.searchFilters };
247
+ const query = addressQuery();
248
+ if (
249
+ !remember ||
250
+ !browser ||
251
+ sameProductListLocation(productListLocationFromQuery(query), next)
252
+ ) {
253
+ return;
254
+ }
255
+ replaceState(
256
+ routeHref({
257
+ path: window.location.pathname,
258
+ query: productListLocationQuery(query, next),
259
+ hash: window.location.hash,
260
+ }),
261
+ currentPage.state,
262
+ );
263
+ }
264
+
160
265
  function cancel() {
161
266
  request?.abort();
162
267
  request = undefined;
@@ -178,11 +283,20 @@ export function useOrionplaceProductSearch(
178
283
  pending.value = !mounted;
179
284
  return;
180
285
  }
286
+ rememberLocation();
181
287
  const current = new AbortController();
182
288
  request = current;
183
289
  pending.value = true;
184
290
  try {
185
- const data = await requestData(body, current.signal);
291
+ const [data] = await Promise.all([
292
+ requestData(body, current.signal),
293
+ preserveFullSearchFacets(
294
+ store,
295
+ toValue(context),
296
+ page.value,
297
+ (full) => requestData(full, current.signal),
298
+ ),
299
+ ]);
186
300
  if (current.signal.aborted) {
187
301
  return;
188
302
  }
@@ -209,11 +323,16 @@ export function useOrionplaceProductSearch(
209
323
  store.setSearchFilters({});
210
324
  void load(true);
211
325
  }
326
+ // A new scope starts from the page and filters of its URL: none after a
327
+ // link, the ones the visitor left after Back or a reload.
212
328
  function reset() {
329
+ const location = addressLocation();
213
330
  pagination.value = {};
214
331
  productIds.value = [];
215
332
  store.setSearchFacets(null);
216
- clearFilters();
333
+ store.setSearchFilters(location.filters);
334
+ page.value = location.page;
335
+ void load();
217
336
  }
218
337
  function turnPage(value: number) {
219
338
  if (
@@ -22,4 +22,13 @@
22
22
  {/if}
23
23
  {/if}
24
24
  {/each}
25
+
26
+ {#each metadata.link ?? [] as link (link.rel)}
27
+ {#if link.href}
28
+ <link
29
+ rel={link.rel}
30
+ href={link.href}
31
+ />
32
+ {/if}
33
+ {/each}
25
34
  </svelte:head>
@@ -103,12 +103,15 @@ const headKey = Symbol('orionplace-head');
103
103
  export interface PageHead {
104
104
  title?: string;
105
105
  meta?: Record<string, any>[];
106
+ // <link> tags, one per `rel` (the canonical address).
107
+ link?: { rel: string; href: string }[];
106
108
  }
107
109
  type HeadReader = () => PageHead;
108
110
 
109
111
  function collectHead(readers: HeadReader[]): PageHead {
110
112
  let title: string | undefined;
111
113
  const meta = new Map<string, Record<string, any>>();
114
+ const link = new Map<string, { rel: string; href: string }>();
112
115
  for (const read of readers) {
113
116
  const head = read();
114
117
  title = head.title ?? title;
@@ -118,8 +121,11 @@ function collectHead(readers: HeadReader[]): PageHead {
118
121
  meta.set(key, tag);
119
122
  }
120
123
  }
124
+ for (const tag of head.link ?? []) {
125
+ link.set(tag.rel, tag);
126
+ }
121
127
  }
122
- return { title, meta: [...meta.values()] };
128
+ return { title, meta: [...meta.values()], link: [...link.values()] };
123
129
  }
124
130
 
125
131
  interface HeadRegistry {
@@ -178,7 +184,9 @@ export function useSeoMeta(source: Record<string, any>) {
178
184
  ? 'og:description'
179
185
  : name === 'ogImage'
180
186
  ? 'og:image'
181
- : name,
187
+ : name === 'ogUrl'
188
+ ? 'og:url'
189
+ : name,
182
190
  content: typeof value === 'function' ? value() : value,
183
191
  })),
184
192
  }));
@@ -3,6 +3,7 @@ import { OrionplaceStore } from '@/lib/orionplace/store.svelte';
3
3
  import { snapshotPageState } from '@/lib/orionplace/pageState';
4
4
  import { loadProductSearchSnapshot } from '@/lib/composables/useOrionplaceProductSearch.svelte';
5
5
  import { requestProductPage } from '@/lib/utils/orionplace/productPage';
6
+ import { productListLocationFromQuery } from '@/lib/utils/orionplace/productListLocation';
6
7
  import { requestTariffPage } from '@/lib/utils/orionplace/tariffPage';
7
8
  import {
8
9
  blogPageNumber,
@@ -81,6 +82,8 @@ export async function loadStorefrontPageData(
81
82
  // A request failure retains the component's own Retry state. Only a
82
83
  // successful response without the record produces a missing-page response.
83
84
  if (store.isReady) {
85
+ // A list opens on the page and filters of its URL (Back, reload).
86
+ const listLocation = productListLocationFromQuery(Object.fromEntries(query));
84
87
  try {
85
88
  if (path === '/browse') {
86
89
  const scope = {
@@ -91,18 +94,25 @@ export async function loadStorefrontPageData(
91
94
  };
92
95
  store.searchQuery = scope.query;
93
96
  data[`orionplace:search:${JSON.stringify(scope)}`] =
94
- await loadProductSearchSnapshot(api, store.facade, scope);
97
+ await loadProductSearchSnapshot(
98
+ api,
99
+ store.facade,
100
+ scope,
101
+ listLocation,
102
+ );
95
103
  } else if (path === '/featured') {
96
104
  data['orionplace:featured'] = await loadProductSearchSnapshot(
97
105
  api,
98
106
  store.facade,
99
107
  { featured: true },
108
+ listLocation,
100
109
  );
101
110
  } else if (path.startsWith('/category/')) {
102
111
  data[`orionplace:category:${id}`] = await loadProductSearchSnapshot(
103
112
  api,
104
113
  store.facade,
105
114
  { categoryId: id },
115
+ listLocation,
106
116
  );
107
117
  } else if (path.startsWith('/product/') && id) {
108
118
  const { product, sellers } = await requestProductPage(
@@ -282,3 +282,71 @@ export function storefrontPageMetadata(
282
282
  keywords: plainText(values.keywords ?? '') || `${title}, ${copy[2]}`,
283
283
  };
284
284
  }
285
+
286
+ /*
287
+ * Addresses for search engines and link previews. The public origin is the
288
+ * storefront host the visitor used (the proxy's Host or X-Forwarded-Host),
289
+ * over HTTPS; a local host keeps HTTP for development.
290
+ */
291
+ export function storefrontPublicOrigin(host: string | null | undefined): string {
292
+ const name = (host ?? '').split(',')[0]!.trim().toLowerCase();
293
+ if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:]+\])(?::\d{1,5})?$/.test(name)) {
294
+ return '';
295
+ }
296
+ const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/.test(name);
297
+ return `${local ? 'http' : 'https'}://${name}`;
298
+ }
299
+
300
+ /**
301
+ * A complete address for a link preview image: a storefront path such as
302
+ * `/ps3/blog/posts/images/5` gets the public origin; without an origin it is
303
+ * left out, since a relative og:image is not loaded by link previews.
304
+ */
305
+ export function storefrontAbsoluteUrl(
306
+ value: string | null | undefined,
307
+ origin: string,
308
+ ): string | undefined {
309
+ if (!value) {
310
+ return undefined;
311
+ }
312
+ try {
313
+ const url = origin ? new URL(value, `${origin}/`) : new URL(value);
314
+ return /^https?:$/.test(url.protocol) ? url.toString() : undefined;
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ // The pages the sitemap lists (runtime/marketplace.mjs), plus plan pages.
321
+ const indexedPage = /^\/(?:|catalog|journal|tariffs|manufacturers|sellers|stores|contact|privacy|(?:category|product|journal)\/\d+|tariffs\/[^/]+\/\d+)$/;
322
+
323
+ /**
324
+ * The canonical address of a page: the sitemap's address for it, the page in
325
+ * the language it shows (`?locale=`). Pages that are not listed, pages with
326
+ * other query parameters (filters, page numbers, private keys) and noindex
327
+ * pages get none.
328
+ */
329
+ export function storefrontCanonicalUrl(
330
+ origin: string,
331
+ pathname: string,
332
+ search: string,
333
+ locale: string,
334
+ robots?: string,
335
+ ): string | undefined {
336
+ const path = pathname.replace(/\/+$/, '') || '/';
337
+ const query = new URLSearchParams(search);
338
+ query.delete('locale');
339
+ if (
340
+ !origin ||
341
+ /noindex/i.test(robots ?? '') ||
342
+ !indexedPage.test(path) ||
343
+ [...query.keys()].length
344
+ ) {
345
+ return undefined;
346
+ }
347
+ const url = new URL(path, `${origin}/`);
348
+ if (locale) {
349
+ url.searchParams.set('locale', locale);
350
+ }
351
+ return url.toString();
352
+ }
@@ -0,0 +1,130 @@
1
+ import type { ProductFacetSelection } from '@/lib/interfaces/orionplace/products/IProductSearchFacets';
2
+
3
+ // Where a product list is: its result page and the chosen filters. Category,
4
+ // search, seller and featured lists keep it in the URL as `?page&filters`, so
5
+ // a reload and the browser's Back button from a product return to the same
6
+ // list. `filters` is the filter selection as JSON; the address is visitor
7
+ // input, so only plain filter values within the limits below are read.
8
+ export interface ProductListLocation {
9
+ page: number;
10
+ filters: ProductFacetSelection;
11
+ }
12
+
13
+ type QueryValue = string | null | undefined | readonly (string | null)[];
14
+
15
+ const locationKeys = ['page', 'filters'];
16
+ const maxPage = 10000;
17
+ const maxFiltersText = 4000;
18
+ const maxFilterKeys = 40;
19
+ const maxFilterIds = 200;
20
+ const maxFilterString = 100;
21
+ const filterKey = /^[a-z][a-z0-9_]{0,63}$/;
22
+
23
+ function first(value: QueryValue) {
24
+ return Array.isArray(value) ? value[0] : (value as string | null | undefined);
25
+ }
26
+
27
+ function filterValue(value: unknown): ProductFacetSelection[string] {
28
+ if (Array.isArray(value)) {
29
+ const ids = value.filter(
30
+ (id): id is number => Number.isSafeInteger(id) && id >= 0,
31
+ );
32
+
33
+ return ids.length === value.length && ids.length && ids.length <= maxFilterIds
34
+ ? ids
35
+ : undefined;
36
+ }
37
+ if (typeof value === 'string') {
38
+ return value && value.length <= maxFilterString ? value : undefined;
39
+ }
40
+ if (typeof value === 'number') {
41
+ return Number.isFinite(value) ? value : undefined;
42
+ }
43
+
44
+ // `false` chooses nothing, as an empty list or text.
45
+ return value === true ? true : undefined;
46
+ }
47
+
48
+ export function productListFiltersFromText(
49
+ text: string | null | undefined,
50
+ ): ProductFacetSelection {
51
+ if (!text || text.length > maxFiltersText) {
52
+ return {};
53
+ }
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = JSON.parse(text);
57
+ } catch {
58
+ return {};
59
+ }
60
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
61
+ return {};
62
+ }
63
+ const result: ProductFacetSelection = {};
64
+ for (const [key, value] of Object.entries(parsed).slice(0, maxFilterKeys)) {
65
+ const checked = filterKey.test(key) ? filterValue(value) : undefined;
66
+ if (checked !== undefined) {
67
+ result[key] = checked;
68
+ }
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ // The filters as URL text, or undefined when nothing is chosen.
75
+ // Keys are sorted, so the same choice always gives the same text.
76
+ export function productListFiltersText(filters: ProductFacetSelection) {
77
+ const chosen = productListFiltersFromText(JSON.stringify(filters));
78
+ const keys = Object.keys(chosen).sort();
79
+
80
+ return keys.length
81
+ ? JSON.stringify(Object.fromEntries(keys.map((key) => [key, chosen[key]])))
82
+ : undefined;
83
+ }
84
+
85
+ export function productListLocationFromQuery(
86
+ query: Record<string, QueryValue>,
87
+ ): ProductListLocation {
88
+ const page = Number(first(query.page));
89
+
90
+ return {
91
+ page: Number.isSafeInteger(page) && page > 0 && page <= maxPage ? page : 1,
92
+ filters: productListFiltersFromText(first(query.filters)),
93
+ };
94
+ }
95
+
96
+ // Compares the URL form, so the same choice in another key order is equal.
97
+ export function sameProductListLocation(
98
+ left: ProductListLocation,
99
+ right: ProductListLocation,
100
+ ) {
101
+ return (
102
+ left.page === right.page &&
103
+ productListFiltersText(left.filters) === productListFiltersText(right.filters)
104
+ );
105
+ }
106
+
107
+ // The query of `next`: other parameters such as `locale` or `q` stay, and the
108
+ // first page without filters adds nothing.
109
+ export function productListLocationQuery(
110
+ query: Record<string, QueryValue>,
111
+ next: ProductListLocation,
112
+ ) {
113
+ const result: Record<string, string> = {};
114
+
115
+ for (const [key, value] of Object.entries(query)) {
116
+ const text = first(value);
117
+ if (typeof text === 'string' && !locationKeys.includes(key)) {
118
+ result[key] = text;
119
+ }
120
+ }
121
+ if (next.page > 1) {
122
+ result.page = String(next.page);
123
+ }
124
+ const filters = productListFiltersText(next.filters);
125
+ if (filters) {
126
+ result.filters = filters;
127
+ }
128
+
129
+ return result;
130
+ }
@@ -14,6 +14,7 @@
14
14
  import { directoryLabels } from '@/lib/utils/orionplace/directoryLabels';
15
15
  import { storeAddress, storePhoneLink } from '@/lib/utils/orionplace/storeDirectory';
16
16
  import { browseLabels } from '@/lib/utils/orionplace/browseLabels';
17
+ import { productListLocationFromQuery } from '@/lib/utils/orionplace/productListLocation';
17
18
  import {
18
19
  fixedScopeFilters,
19
20
  hasFixedSearchScope,
@@ -68,7 +69,13 @@
68
69
  // browser.
69
70
  const serverResults = useOrionplaceServerData(
70
71
  `orionplace:search:${JSON.stringify(scope.value)}`,
71
- (api) => loadProductSearchSnapshot(api, store, scope.value),
72
+ (api) =>
73
+ loadProductSearchSnapshot(
74
+ api,
75
+ store,
76
+ scope.value,
77
+ productListLocationFromQuery(route.query),
78
+ ),
72
79
  );
73
80
  const results = useOrionplaceProductSearch(scope, {
74
81
  initial: serverResults.value,
@@ -11,6 +11,7 @@
11
11
  import { useOrionplaceCategoryProducts } from '@/lib/composables/useOrionplaceCategoryProducts.svelte';
12
12
  import { useOrionplaceServerData } from '@/lib/composables/useOrionplaceServerData.svelte';
13
13
  import { loadProductSearchSnapshot } from '@/lib/composables/useOrionplaceProductSearch.svelte';
14
+ import { productListLocationFromQuery } from '@/lib/utils/orionplace/productListLocation';
14
15
  import { useOrionplaceStorefront } from '@/lib/composables/useOrionplaceStorefront.svelte';
15
16
  import {
16
17
  getAvailableNestedValueFromObject,
@@ -31,12 +32,19 @@
31
32
  /^\d+$/.test(String(route.params.id)) ? Number(route.params.id) : 0,
32
33
  );
33
34
  const category = computed(() => store.getCatalogCategory(id.value));
34
- // The server render loads the first result page with the visitor's
35
- // cookies, so the page HTML lists the category's products and prices.
36
- // Filters and further pages load in the browser.
35
+ // The server render loads the result page of the URL (page and filters,
36
+ // the first page by default) with the visitor's cookies, so the page HTML
37
+ // lists the category's products and prices. Later changes load in the
38
+ // browser.
37
39
  const serverResults = useOrionplaceServerData(
38
40
  `orionplace:category:${id.value}`,
39
- (api) => loadProductSearchSnapshot(api, store, { categoryId: id.value }),
41
+ (api) =>
42
+ loadProductSearchSnapshot(
43
+ api,
44
+ store,
45
+ { categoryId: id.value },
46
+ productListLocationFromQuery(route.query),
47
+ ),
40
48
  );
41
49
  const results = useOrionplaceCategoryProducts(id, serverResults.value);
42
50
  const { actions, variants, selection } = useOrionplaceStorefront();
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { routeHref } from '@/lib/orionplace/navigation.svelte';
3
3
  import { computed } from '@/lib/orionplace/reactivity.svelte';
4
- import { navigateTo } from '@/lib/orionplace/navigation.svelte';
4
+ import { navigateTo, useRoute } from '@/lib/orionplace/navigation.svelte';
5
5
  import { useOrionplacePageSeo } from '@/lib/composables/useOrionplacePageSeo.svelte';
6
6
  import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
7
7
  import { useOrionplaceFeaturedProducts } from '@/lib/composables/useOrionplaceFeaturedProducts.svelte';
@@ -10,6 +10,7 @@
10
10
  import { useOrionplaceStorefront } from '@/lib/composables/useOrionplaceStorefront.svelte';
11
11
  import { featuredLabels } from '@/lib/utils/orionplace/featuredLabels';
12
12
  import { browseLabels } from '@/lib/utils/orionplace/browseLabels';
13
+ import { productListLocationFromQuery } from '@/lib/utils/orionplace/productListLocation';
13
14
  import ProductSearchResults from '@/lib/components/orionplace/search/ProductSearchResults.svelte';
14
15
  import OrionplaceEmptyState from '@/lib/components/orionplace/shared/OrionplaceEmptyState.svelte';
15
16
 
@@ -19,10 +20,16 @@
19
20
  let { ...attributes }: Props = $props();
20
21
 
21
22
  const store = useOrionplaceStore();
23
+ const route = useRoute();
22
24
  // Featured products depend on the signed-in customer's searches: the
23
- // server render loads the first page with the visitor's cookies.
25
+ // server render loads the page of the URL with the visitor's cookies.
24
26
  const serverResults = useOrionplaceServerData('orionplace:featured', (api) =>
25
- loadProductSearchSnapshot(api, store, { featured: true }),
27
+ loadProductSearchSnapshot(
28
+ api,
29
+ store,
30
+ { featured: true },
31
+ productListLocationFromQuery(route.query),
32
+ ),
26
33
  );
27
34
  const results = useOrionplaceFeaturedProducts(serverResults.value);
28
35
  const { actions, variants } = useOrionplaceStorefront();