orionplace-svelte 0.7.3 → 0.7.4

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/README.md CHANGED
@@ -521,6 +521,8 @@ and selected language, including client navigation. Public events and banner
521
521
  images come from Application Load.
522
522
  Pages with a canonical address also link the same page in the other storefront languages (`hreflang`) and set `og:type` and `og:locale`; a journal article adds its publication date (`article:published_time`) and schema.org `BlogPosting` data. The store icon (`logos.ico`) is the page's `<link rel="icon">`. An unknown `?locale` opens the default language.
523
523
 
524
+ List pages (journal, categories, stores) link their previous and next pages as real links (`?page=2`). Each later page has its own canonical address and a title with its page number; a journal page past the last one answers 404.
525
+
524
526
  ## Application Load starter
525
527
 
526
528
  The root layout load awaits `POST /api/appload` before your pages
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-svelte",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
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",
@@ -669,6 +669,7 @@
669
669
  <StorefrontPagination
670
670
  pagination={pagination.value}
671
671
  pending={Boolean(task.state.pending)}
672
+ linked
672
673
  onChange={changePage}
673
674
  />
674
675
  </div>
@@ -6,6 +6,8 @@
6
6
  import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
7
7
  import { browseLabels } from '@/lib/utils/orionplace/browseLabels';
8
8
  import { hasSearchFilters } from '@/lib/utils/orionplace/searchFilters';
9
+ import { storefrontPageHref } from '@/lib/utils/orionplace/pageSeo';
10
+ import { currentSearch, useRoute } from '@/lib/orionplace/navigation.svelte';
9
11
  import SearchFilters from '@/lib/components/orionplace/search/SearchFilters.svelte';
10
12
  import ProductGroup from '@/lib/components/orionplace/products/ProductGroup.svelte';
11
13
  import type { ProductFacetSelection } from '@/lib/interfaces/orionplace/products/IProductSearchFacets';
@@ -195,6 +197,36 @@
195
197
  },
196
198
  );
197
199
  const labels = computed(() => browseLabels(selectionStore.localeCode));
200
+ // Every list of this component keeps its page in the address (`?page=`),
201
+ // so previous and next are links search engines follow; a click changes
202
+ // the page in place.
203
+ const route = useRoute();
204
+ const search = computed(() => currentSearch());
205
+ const previousHref = computed(() =>
206
+ !inputs.pending && inputs.page > 1
207
+ ? storefrontPageHref(route.path, search.value, inputs.page - 1)
208
+ : undefined,
209
+ );
210
+ const nextHref = computed(() =>
211
+ !inputs.pending && !inputs.failed && inputs.hasNext
212
+ ? storefrontPageHref(route.path, search.value, inputs.page + 1)
213
+ : undefined,
214
+ );
215
+
216
+ // A link opened in a new tab or window keeps its default.
217
+ function followPage(event: MouseEvent, value: number) {
218
+ if (
219
+ event.button !== 0 ||
220
+ event.metaKey ||
221
+ event.ctrlKey ||
222
+ event.shiftKey ||
223
+ event.altKey
224
+ ) {
225
+ return;
226
+ }
227
+ event.preventDefault();
228
+ emit('changePage', value);
229
+ }
198
230
  const filtersOpen = ref(false);
199
231
  const hasFilters = computed(() =>
200
232
  hasSearchFilters(
@@ -571,17 +603,26 @@
571
603
  `,
572
604
  ]}
573
605
  >
574
- <button
575
- type="button"
576
- disabled={inputs.pending || inputs.page === 1}
577
- onclick={() => {
578
- emit('changePage', inputs.page - 1);
606
+ <svelte:element
607
+ this={previousHref.value ? 'a' : 'button'}
608
+ type={previousHref.value ? undefined : 'button'}
609
+ href={previousHref.value}
610
+ rel={previousHref.value ? 'prev' : undefined}
611
+ disabled={previousHref.value
612
+ ? undefined
613
+ : inputs.pending || inputs.page === 1}
614
+ onclick={(event: MouseEvent) => {
615
+ if (previousHref.value) {
616
+ followPage(event, inputs.page - 1);
617
+ } else {
618
+ emit('changePage', inputs.page - 1);
619
+ }
579
620
  }}
580
621
  class={[
581
622
  `
582
623
  inline-flex h-11 items-center justify-center px-5 [font-family:inherit]
583
624
  text-xs font-medium uppercase tracking-[0.08em] text-orionplace-text
584
- border border-solid border-orionplace-border bg-transparent
625
+ no-underline border border-solid border-orionplace-border bg-transparent
585
626
  cursor-pointer outline-none hover:border-orionplace-text
586
627
  focus-visible:border-orionplace-text disabled:cursor-not-allowed
587
628
  disabled:opacity-40
@@ -589,7 +630,7 @@
589
630
  ]}
590
631
  >
591
632
  ← {labels.value.previous}
592
- </button>
633
+ </svelte:element>
593
634
  <span
594
635
  class={[
595
636
  'text-xs uppercase tracking-[0.08em] tabular-nums text-orionplace-muted',
@@ -601,17 +642,26 @@
601
642
  {labels.value.of} {inputs.pages}
602
643
  {/if}
603
644
  </span>
604
- <button
605
- type="button"
606
- disabled={inputs.pending || inputs.failed || !inputs.hasNext}
607
- onclick={() => {
608
- emit('changePage', inputs.page + 1);
645
+ <svelte:element
646
+ this={nextHref.value ? 'a' : 'button'}
647
+ type={nextHref.value ? undefined : 'button'}
648
+ href={nextHref.value}
649
+ rel={nextHref.value ? 'next' : undefined}
650
+ disabled={nextHref.value
651
+ ? undefined
652
+ : inputs.pending || inputs.failed || !inputs.hasNext}
653
+ onclick={(event: MouseEvent) => {
654
+ if (nextHref.value) {
655
+ followPage(event, inputs.page + 1);
656
+ } else {
657
+ emit('changePage', inputs.page + 1);
658
+ }
609
659
  }}
610
660
  class={[
611
661
  `
612
662
  inline-flex h-11 items-center justify-center px-5 [font-family:inherit]
613
663
  text-xs font-medium uppercase tracking-[0.08em] text-orionplace-text
614
- border border-solid border-orionplace-border bg-transparent
664
+ no-underline border border-solid border-orionplace-border bg-transparent
615
665
  cursor-pointer outline-none hover:border-orionplace-text
616
666
  focus-visible:border-orionplace-text disabled:cursor-not-allowed
617
667
  disabled:opacity-40
@@ -619,7 +669,7 @@
619
669
  ]}
620
670
  >
621
671
  {labels.value.next} →
622
- </button>
672
+ </svelte:element>
623
673
  </nav>
624
674
  {/if}
625
675
  </div>
@@ -8,6 +8,10 @@ the list start while accounting for a fixed header and reduced-motion preference
8
8
  focus moves to the list's first heading. Both controls are disabled while the request
9
9
  runs.
10
10
 
11
+ With `linked`, for lists that keep their page number in the address (`?page=2`),
12
+ previous and next are links to those addresses, so search engines reach every page of
13
+ the list; a click still changes the page in place.
14
+
11
15
  Source: `src/lib/components/orionplace/shared/StorefrontPagination.svelte`.
12
16
 
13
17
  ## Inputs
@@ -18,6 +22,7 @@ Source: `src/lib/components/orionplace/shared/StorefrontPagination.svelte`.
18
22
  | `pending` | `boolean` | Optional |
19
23
  | `scrollTarget` | `HTMLElement \| string \| null` | Optional |
20
24
  | `compact` | `boolean` | Optional |
25
+ | `linked` | `boolean` | Optional |
21
26
 
22
27
  ## Callbacks
23
28
 
@@ -3,6 +3,8 @@
3
3
  import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
4
4
  import { featureLabels } from '@/lib/utils/orionplace/featureLabels';
5
5
  import { scrollToPaginatedList } from '@/lib/utils/orionplace/paginationScroll';
6
+ import { storefrontPageHref } from '@/lib/utils/orionplace/pageSeo';
7
+ import { currentSearch, useRoute } from '@/lib/orionplace/navigation.svelte';
6
8
  import type { IApiPagination } from '@/lib/interfaces/orionplace/accounts/IProfileData';
7
9
  import '@/lib/components/orionplace/shared/features.css';
8
10
 
@@ -11,6 +13,12 @@
11
13
  pending?: boolean;
12
14
  scrollTarget?: HTMLElement | string | null;
13
15
  compact?: boolean;
16
+ /**
17
+ * The list keeps its page number in the address (`?page=`): previous and
18
+ * next are links to those addresses, so search engines reach every page.
19
+ * A click still changes the page in place.
20
+ */
21
+ linked?: boolean;
14
22
  onChange?: (...args: [page: number]) => unknown;
15
23
  [attribute: string]: any;
16
24
  };
@@ -19,6 +27,7 @@
19
27
  pending: input_pending,
20
28
  scrollTarget: input_scrollTarget,
21
29
  compact: input_compact,
30
+ linked: input_linked,
22
31
  onChange: callback_onChange,
23
32
  ...attributes
24
33
  }: Props = $props();
@@ -35,6 +44,9 @@
35
44
  get compact() {
36
45
  return input_compact;
37
46
  },
47
+ get linked() {
48
+ return input_linked;
49
+ },
38
50
  };
39
51
  const emit = (event: string, ...args: any[]) => {
40
52
  const handlers: Record<string, ((...args: any[]) => unknown) | undefined> =
@@ -48,11 +60,46 @@
48
60
  const labels = computed(() => featureLabels(selectionStore.localeCode));
49
61
  const page = computed(() => inputs.pagination.page ?? 1);
50
62
  const nav = ref<HTMLElement>();
63
+ const route = useRoute();
64
+ const previousPage = computed(
65
+ () => inputs.pagination.previous_page_number || page.value - 1,
66
+ );
67
+ const nextPage = computed(() =>
68
+ typeof inputs.pagination.next_page_number === 'number'
69
+ ? inputs.pagination.next_page_number
70
+ : page.value + 1,
71
+ );
72
+ const search = computed(() => currentSearch());
73
+ const previousHref = computed(() =>
74
+ inputs.linked && !inputs.pending && inputs.pagination.has_previous
75
+ ? storefrontPageHref(route.path, search.value, previousPage.value)
76
+ : undefined,
77
+ );
78
+ const nextHref = computed(() =>
79
+ inputs.linked && !inputs.pending && inputs.pagination.has_next
80
+ ? storefrontPageHref(route.path, search.value, nextPage.value)
81
+ : undefined,
82
+ );
51
83
 
52
84
  function change(value: number) {
53
85
  emit('change', value);
54
86
  scrollToPaginatedList(nav.value, inputs.scrollTarget);
55
87
  }
88
+
89
+ // A link opened in a new tab or window keeps its default.
90
+ function follow(event: MouseEvent, value: number, act: (value: number) => void) {
91
+ if (
92
+ event.button !== 0 ||
93
+ event.metaKey ||
94
+ event.ctrlKey ||
95
+ event.shiftKey ||
96
+ event.altKey
97
+ ) {
98
+ return;
99
+ }
100
+ event.preventDefault();
101
+ act(value);
102
+ }
56
103
  </script>
57
104
 
58
105
  {#if inputs.pagination.has_other_pages || inputs.pagination.has_next || inputs.pagination.has_previous}
@@ -67,13 +114,22 @@
67
114
  attributes.class,
68
115
  ]}
69
116
  >
70
- <button
71
- type="button"
117
+ <svelte:element
118
+ this={previousHref.value ? 'a' : 'button'}
119
+ type={previousHref.value ? undefined : 'button'}
120
+ href={previousHref.value}
121
+ rel={previousHref.value ? 'prev' : undefined}
72
122
  aria-label={inputs.compact ? labels.value.previous : undefined}
73
123
  title={inputs.compact ? labels.value.previous : undefined}
74
- disabled={inputs.pending || !inputs.pagination.has_previous}
75
- onclick={() => {
76
- change(inputs.pagination.previous_page_number || page.value - 1);
124
+ disabled={previousHref.value
125
+ ? undefined
126
+ : inputs.pending || !inputs.pagination.has_previous}
127
+ onclick={(event: MouseEvent) => {
128
+ if (previousHref.value) {
129
+ follow(event, previousPage.value, change);
130
+ } else {
131
+ change(previousPage.value);
132
+ }
77
133
  }}
78
134
  class={['op-feature-button']}
79
135
  >
@@ -93,7 +149,7 @@
93
149
  {:else}
94
150
  {labels.value.previous}
95
151
  {/if}
96
- </button>
152
+ </svelte:element>
97
153
  <span class={['tabular-nums']}>
98
154
  {labels.value.page}
99
155
  {page.value}
@@ -101,17 +157,22 @@
101
157
  / {inputs.pagination.pages}
102
158
  {/if}
103
159
  </span>
104
- <button
105
- type="button"
160
+ <svelte:element
161
+ this={nextHref.value ? 'a' : 'button'}
162
+ type={nextHref.value ? undefined : 'button'}
163
+ href={nextHref.value}
164
+ rel={nextHref.value ? 'next' : undefined}
106
165
  aria-label={inputs.compact ? labels.value.next : undefined}
107
166
  title={inputs.compact ? labels.value.next : undefined}
108
- disabled={inputs.pending || !inputs.pagination.has_next}
109
- onclick={() => {
110
- change(
111
- typeof inputs.pagination.next_page_number === 'number'
112
- ? inputs.pagination.next_page_number
113
- : page.value + 1,
114
- );
167
+ disabled={nextHref.value
168
+ ? undefined
169
+ : inputs.pending || !inputs.pagination.has_next}
170
+ onclick={(event: MouseEvent) => {
171
+ if (nextHref.value) {
172
+ follow(event, nextPage.value, change);
173
+ } else {
174
+ change(nextPage.value);
175
+ }
115
176
  }}
116
177
  class={['op-feature-button']}
117
178
  >
@@ -131,6 +192,6 @@
131
192
  {:else}
132
193
  {labels.value.next}
133
194
  {/if}
134
- </button>
195
+ </svelte:element>
135
196
  </nav>
136
197
  {/if}
@@ -397,6 +397,7 @@
397
397
  <StorefrontPagination
398
398
  pagination={pagination.value}
399
399
  pending={busy.value}
400
+ linked={!inputs.storeId}
400
401
  onChange={(page: number) => (inputs.storeId ? load(page) : open(page))}
401
402
  />
402
403
  </section>
@@ -2,7 +2,7 @@ import { page as currentPage } from '$app/state';
2
2
  import { browser } from '$app/environment';
3
3
  import { computed, toValue } from '@/lib/orionplace/reactivity.svelte';
4
4
  import type { MaybeRefOrGetter } from '@/lib/orionplace/reactivity.svelte';
5
- import { useHead, useSeoMeta } from '@/lib/orionplace/navigation.svelte';
5
+ import { currentSearch, useHead, useSeoMeta } from '@/lib/orionplace/navigation.svelte';
6
6
  import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
7
7
  import {
8
8
  storefrontAbsoluteUrl,
@@ -11,6 +11,7 @@ import {
11
11
  storefrontOpenGraphLocale,
12
12
  storefrontOpenGraphType,
13
13
  storefrontPageMetadata,
14
+ storefrontPagedText,
14
15
  storefrontPublicOrigin,
15
16
  } from '@/lib/utils/orionplace/pageSeo';
16
17
  import type { StorefrontSeoPage } from '@/lib/utils/orionplace/pageSeo';
@@ -52,22 +53,35 @@ export function useOrionplacePageSeo(
52
53
  storefrontCanonicalUrl(
53
54
  origin,
54
55
  currentPage.url.pathname,
55
- currentPage.url.search,
56
+ currentSearch(),
56
57
  toValue(locale),
57
58
  toValue(values.robots),
58
59
  ),
59
60
  );
60
61
 
62
+ // A later list page (`?page=2`) names its page number.
63
+ const paged = computed(() => ({
64
+ title:
65
+ storefrontPagedText(metadata.value.title, canonical.value, toValue(locale)) ??
66
+ metadata.value.title,
67
+ description:
68
+ storefrontPagedText(
69
+ metadata.value.description,
70
+ canonical.value,
71
+ toValue(locale),
72
+ ) ?? metadata.value.description,
73
+ }));
74
+
61
75
  // Link previews use the same title and description as the page; the
62
76
  // image is the record's first picture when the page has one, as a
63
77
  // complete address.
64
78
  const seo = {
65
- title: () => metadata.value.title,
66
- description: () => metadata.value.description,
79
+ title: () => paged.value.title,
80
+ description: () => paged.value.description,
67
81
  keywords: () => metadata.value.keywords,
68
82
  robots: () => toValue(values.robots),
69
- ogTitle: () => metadata.value.title,
70
- ogDescription: () => metadata.value.description,
83
+ ogTitle: () => paged.value.title,
84
+ ogDescription: () => paged.value.description,
71
85
  ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
72
86
  ogUrl: () => canonical.value,
73
87
  ogType: () => storefrontOpenGraphType(toValue(page)),
@@ -9,7 +9,7 @@ import type { MaybeRefOrGetter } from '@/lib/orionplace/reactivity.svelte';
9
9
  import { browser } from '$app/environment';
10
10
  import { replaceState } from '$app/navigation';
11
11
  import { page as currentPage } from '$app/state';
12
- import { routeHref } from '@/lib/orionplace/navigation.svelte';
12
+ import { noteShallowAddress, routeHref } from '@/lib/orionplace/navigation.svelte';
13
13
  import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
14
14
  import { useOrionplaceApi } from '@/lib/composables/useOrionplaceApi.svelte';
15
15
  import { useOrionplaceProgress } from '@/lib/composables/useOrionplaceProgress.svelte';
@@ -260,6 +260,7 @@ export function useOrionplaceProductSearch(
260
260
  }),
261
261
  currentPage.state,
262
262
  );
263
+ noteShallowAddress();
263
264
  }
264
265
 
265
266
  function cancel() {
@@ -6,6 +6,25 @@ import { getContext, setContext, untrack } from 'svelte';
6
6
  import { computed, ref } from '@/lib/orionplace/reactivity.svelte';
7
7
  import type { Ref } from '@/lib/orionplace/reactivity.svelte';
8
8
 
9
+ // A shallow replaceState (a product list's page and filters) leaves
10
+ // `page.url` as it was, so the address bar is read again after each one.
11
+ const shallowAddress = $state({ version: 0 });
12
+
13
+ /** Call after a shallow replaceState that changed the address. */
14
+ export function noteShallowAddress() {
15
+ shallowAddress.version += 1;
16
+ }
17
+
18
+ /**
19
+ * The query string of the address shown (`?locale=en&page=2`), also after a
20
+ * shallow replaceState; reactive in components and computed values.
21
+ */
22
+ export function currentSearch(): string {
23
+ const fromPage = page.url.search;
24
+ void shallowAddress.version;
25
+ return browser ? window.location.search : fromPage;
26
+ }
27
+
9
28
  export function routeHref(target: unknown): string {
10
29
  if (typeof target === 'string') {
11
30
  return target;
@@ -10,8 +10,10 @@ import {
10
10
  blogPageSize,
11
11
  blogViewType,
12
12
  blogPostsRequest,
13
+ journalPagePastEnd,
13
14
  requestBlogPost,
14
15
  } from '@/lib/utils/orionplace/blogLayout';
16
+ import type { IBlogJournalPage } from '@/lib/utils/orionplace/blogLayout';
15
17
  import type { OrionplaceApi } from '@/lib/utils/orionplace/api';
16
18
  import type { OrionplaceSnapshot } from '@/lib/orionplace/appLoadData';
17
19
 
@@ -66,6 +68,7 @@ export async function loadStorefrontPageData(
66
68
  }
67
69
  const api = createApi(store);
68
70
  const data: Record<string, unknown> = {};
71
+ let journalPastEnd = false;
69
72
  const numberId = (value: string | null | undefined) =>
70
73
  /^[1-9]\d*$/.test(value ?? '') ? Number(value) : undefined;
71
74
  const id = numberId(event.params.id);
@@ -129,7 +132,7 @@ export async function loadStorefrontPageData(
129
132
  const pageSize = blogPageSize(
130
133
  blogViewType(store.init?.visual_settings?.blog_view_type),
131
134
  );
132
- data[`orionplace:journal:${page}:${pageSize}`] = {
135
+ const journalPage: IBlogJournalPage = {
133
136
  page,
134
137
  pageSize,
135
138
  data: await api.request(
@@ -137,6 +140,10 @@ export async function loadStorefrontPageData(
137
140
  blogPostsRequest({}, page, pageSize),
138
141
  ),
139
142
  };
143
+ data[`orionplace:journal:${page}:${pageSize}`] = journalPage;
144
+ // A page number past the last page has no articles: 404, so search
145
+ // engines drop it instead of indexing an empty list.
146
+ journalPastEnd = journalPagePastEnd(page, journalPage);
140
147
  } else if (path.startsWith('/journal/') && id) {
141
148
  const post = await requestBlogPost(api, id);
142
149
  data[`orionplace:journal-post:${id}`] = { post: post ?? null };
@@ -158,6 +165,7 @@ export async function loadStorefrontPageData(
158
165
  ? data[`orionplace:tariff:${id}`]
159
166
  : undefined;
160
167
  if (
168
+ journalPastEnd ||
161
169
  (record as { found?: boolean } | undefined)?.found === false ||
162
170
  (data[`orionplace:journal-post:${id}`] as { post?: unknown } | undefined)
163
171
  ?.post === null
@@ -93,3 +93,16 @@ export function blogCoverInitial(title: string) {
93
93
  export function blogCoverPattern(postId: number) {
94
94
  return Math.abs(postId) % 3;
95
95
  }
96
+
97
+ /**
98
+ * Whether a journal page number lies past the last page: the server render
99
+ * loaded that page (no filters) and it has no articles. Page 1 of an empty
100
+ * journal is a page of its own.
101
+ */
102
+ export function journalPagePastEnd(
103
+ page: number,
104
+ loaded: IBlogJournalPage | null | undefined,
105
+ ): boolean {
106
+ const posts = loaded?.data?.posts?.data;
107
+ return page > 1 && loaded?.page === page && Array.isArray(posts) && !posts.length;
108
+ }
@@ -460,11 +460,15 @@ export function storefrontAbsoluteUrl(
460
460
  // The pages the sitemap lists (runtime/marketplace.mjs), plus plan pages.
461
461
  const indexedPage = /^\/(?:|catalog|journal|tariffs|manufacturers|sellers|stores|contact|privacy|(?:category|product|journal)\/\d+|tariffs\/[^/]+\/\d+)$/;
462
462
 
463
+ // Listed pages that are lists with page numbers (`?page=2`).
464
+ const pagedList = /^\/(?:journal|stores|category\/\d+)$/;
465
+
463
466
  /**
464
467
  * The canonical address of a page: the sitemap's address for it, the page in
465
- * the language it shows (`?locale=`). Pages that are not listed, pages with
466
- * other query parameters (filters, page numbers, private keys) and noindex
467
- * pages get none.
468
+ * the language it shows (`?locale=`). A later page of a list is a page of its
469
+ * own (`?locale=en&page=2`, Google's pagination guide), linked from the
470
+ * list's page links. Pages that are not listed, pages with other query
471
+ * parameters (filters, private keys) and noindex pages get none.
468
472
  */
469
473
  export function storefrontCanonicalUrl(
470
474
  origin: string,
@@ -473,9 +477,13 @@ export function storefrontCanonicalUrl(
473
477
  locale: string,
474
478
  robots?: string,
475
479
  ): string | undefined {
476
- const path = pathname.replace(/\/+$/, '') || '/';
477
- const query = new URLSearchParams(search);
480
+ const path = pathname.replace(/\/+$/, '') || '/';
481
+ const query = new URLSearchParams(search);
482
+ const pageNumber = query.get('page');
478
483
  query.delete('locale');
484
+ if (pageNumber !== null && pagedList.test(path) && /^[1-9]\d{0,5}$/.test(pageNumber)) {
485
+ query.delete('page');
486
+ }
479
487
  if (
480
488
  !origin ||
481
489
  /noindex/i.test(robots ?? '') ||
@@ -484,13 +492,56 @@ export function storefrontCanonicalUrl(
484
492
  ) {
485
493
  return undefined;
486
494
  }
487
- const url = new URL(path, `${origin}/`);
495
+ const url = new URL(path, `${origin}/`);
488
496
  if (locale) {
489
497
  url.searchParams.set('locale', locale);
490
498
  }
499
+ if (pageNumber && Number(pageNumber) > 1 && pagedList.test(path)) {
500
+ url.searchParams.set('page', pageNumber);
501
+ }
491
502
  return url.toString();
492
503
  }
493
504
 
505
+ /**
506
+ * A later page of a list repeats the list's title and description; the page
507
+ * number keeps them distinct (search engines report repeated titles as
508
+ * duplicates). The number comes from the canonical address, so only the
509
+ * indexed list pages get it.
510
+ */
511
+ export function storefrontPagedText(
512
+ text: string | undefined,
513
+ canonical: string | undefined,
514
+ locale: string,
515
+ ): string | undefined {
516
+ const page = canonical ? Number(new URL(canonical).searchParams.get('page')) : 0;
517
+ if (!text || !(page > 1)) {
518
+ return text;
519
+ }
520
+ const words: Record<string, string> = { ru: 'страница', es: 'página' };
521
+ const word = words[storefrontCopyLanguage(locale)] ?? 'page';
522
+ return `${text} · ${word} ${page}`;
523
+ }
524
+
525
+ /**
526
+ * The address of another page of the list on screen: the current address
527
+ * with `page=` set (left out for page 1), every other parameter kept. Page
528
+ * links are real links, so search engines reach every page of a list.
529
+ */
530
+ export function storefrontPageHref(
531
+ pathname: string,
532
+ search: string,
533
+ page: number,
534
+ ): string {
535
+ const query = new URLSearchParams(search);
536
+ if (page > 1) {
537
+ query.set('page', String(page));
538
+ } else {
539
+ query.delete('page');
540
+ }
541
+ const text = query.toString();
542
+ return `${pathname}${text ? `?${text}` : ''}`;
543
+ }
544
+
494
545
  /** og:type of a page: a product and a journal article have their own kinds. */
495
546
  export function storefrontOpenGraphType(
496
547
  page: StorefrontSeoPage,
@@ -605,7 +656,7 @@ export function storefrontArticleData(input: {
605
656
  */
606
657
  export function storefrontIconLinks(
607
658
  icon: string | null | undefined,
608
- ): { key: string; rel: string; href: string }[] {
659
+ ): { key: string; rel: 'icon'; href: string }[] {
609
660
  const href = storefrontAbsoluteUrl(icon, '');
610
661
  return href ? [{ key: 'icon', rel: 'icon', href }] : [];
611
662
  }