orionplace-nuxt 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 +2 -0
- package/package.json +1 -1
- package/templates/app/components/orionplace/blog/StorefrontJournal.vue +1 -0
- package/templates/app/components/orionplace/search/ProductSearchResults.vue +60 -12
- package/templates/app/components/orionplace/shared/StorefrontPagination.README.md +5 -0
- package/templates/app/components/orionplace/shared/StorefrontPagination.vue +63 -16
- package/templates/app/components/orionplace/stores/StorefrontStores.vue +1 -0
- package/templates/app/composables/useOrionplacePageSeo.ts +23 -6
- package/templates/app/pages/journal/index.vue +7 -0
- package/templates/app/utils/orionplace/blogLayout.ts +13 -0
- package/templates/app/utils/orionplace/pageSeo.ts +58 -7
package/README.md
CHANGED
|
@@ -509,6 +509,8 @@ through `useHead()`. Profile and logout pages remain excluded from indexing;
|
|
|
509
509
|
search results retain their `noindex, follow` policy.
|
|
510
510
|
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.
|
|
511
511
|
|
|
512
|
+
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.
|
|
513
|
+
|
|
512
514
|
## Documentation
|
|
513
515
|
|
|
514
516
|
- [Create a Nuxt frontend](https://orionmarket.place/en/sandbox/new-project/nuxt)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-nuxt",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Nuxt 4 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",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref, watch } from 'vue';
|
|
3
|
+
import { useRoute } from '#imports';
|
|
3
4
|
import { useOrionplaceStableHeight } from '@/composables/useOrionplaceStableHeight';
|
|
4
5
|
import { useOrionplaceBrowseStore } from '@/stores/orionplaceBrowse';
|
|
5
6
|
import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
|
|
6
7
|
import { browseLabels } from '@/utils/orionplace/browseLabels';
|
|
7
8
|
import { hasSearchFilters } from '@/utils/orionplace/searchFilters';
|
|
9
|
+
import { storefrontPageHref } from '@/utils/orionplace/pageSeo';
|
|
8
10
|
import SearchFilters from '@/components/orionplace/search/SearchFilters.vue';
|
|
9
11
|
import ProductGroup from '@/components/orionplace/products/ProductGroup.vue';
|
|
10
12
|
import type { ProductFacetSelection } from '@/interfaces/orionplace/products/IProductSearchFacets';
|
|
@@ -95,6 +97,38 @@
|
|
|
95
97
|
}
|
|
96
98
|
});
|
|
97
99
|
const labels = computed(() => browseLabels(selectionStore.localeCode));
|
|
100
|
+
// Every list of this component keeps its page in the address (`?page=`),
|
|
101
|
+
// so previous and next are links search engines follow; a click changes
|
|
102
|
+
// the page in place.
|
|
103
|
+
const route = useRoute();
|
|
104
|
+
const search = computed(
|
|
105
|
+
() => new URL(route.fullPath, 'http://storefront').search,
|
|
106
|
+
);
|
|
107
|
+
const previousHref = computed(() =>
|
|
108
|
+
!props.pending && props.page > 1
|
|
109
|
+
? storefrontPageHref(route.path, search.value, props.page - 1)
|
|
110
|
+
: undefined,
|
|
111
|
+
);
|
|
112
|
+
const nextHref = computed(() =>
|
|
113
|
+
!props.pending && !props.failed && props.hasNext
|
|
114
|
+
? storefrontPageHref(route.path, search.value, props.page + 1)
|
|
115
|
+
: undefined,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// A link opened in a new tab or window keeps its default.
|
|
119
|
+
function followPage(event: MouseEvent, value: number) {
|
|
120
|
+
if (
|
|
121
|
+
event.button !== 0 ||
|
|
122
|
+
event.metaKey ||
|
|
123
|
+
event.ctrlKey ||
|
|
124
|
+
event.shiftKey ||
|
|
125
|
+
event.altKey
|
|
126
|
+
) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
emit('changePage', value);
|
|
131
|
+
}
|
|
98
132
|
const filtersOpen = ref(false);
|
|
99
133
|
const hasFilters = computed(() =>
|
|
100
134
|
hasSearchFilters(
|
|
@@ -410,22 +444,29 @@
|
|
|
410
444
|
"
|
|
411
445
|
data-search-pagination
|
|
412
446
|
>
|
|
413
|
-
<
|
|
414
|
-
|
|
415
|
-
:
|
|
447
|
+
<component
|
|
448
|
+
:is="previousHref ? 'a' : 'button'"
|
|
449
|
+
:type="previousHref ? undefined : 'button'"
|
|
450
|
+
:href="previousHref"
|
|
451
|
+
:rel="previousHref ? 'prev' : undefined"
|
|
452
|
+
:disabled="previousHref ? undefined : pending || page === 1"
|
|
416
453
|
class="
|
|
417
454
|
inline-flex h-11 items-center justify-center px-5
|
|
418
455
|
[font-family:inherit] text-xs font-medium uppercase tracking-[0.08em]
|
|
419
|
-
text-orionplace-text
|
|
456
|
+
text-orionplace-text no-underline
|
|
420
457
|
border border-solid border-orionplace-border bg-transparent
|
|
421
458
|
cursor-pointer outline-none
|
|
422
459
|
hover:border-orionplace-text focus-visible:border-orionplace-text
|
|
423
460
|
disabled:cursor-not-allowed disabled:opacity-40
|
|
424
461
|
"
|
|
425
|
-
@click="
|
|
462
|
+
@click="
|
|
463
|
+
previousHref
|
|
464
|
+
? followPage($event, page - 1)
|
|
465
|
+
: emit('changePage', page - 1)
|
|
466
|
+
"
|
|
426
467
|
>
|
|
427
468
|
← {{ labels.previous }}
|
|
428
|
-
</
|
|
469
|
+
</component>
|
|
429
470
|
<span
|
|
430
471
|
class="
|
|
431
472
|
text-xs uppercase tracking-[0.08em] tabular-nums
|
|
@@ -435,22 +476,29 @@
|
|
|
435
476
|
{{ labels.page }} {{ page }}
|
|
436
477
|
<template v-if="pages">{{ labels.of }} {{ pages }}</template>
|
|
437
478
|
</span>
|
|
438
|
-
<
|
|
439
|
-
|
|
440
|
-
:
|
|
479
|
+
<component
|
|
480
|
+
:is="nextHref ? 'a' : 'button'"
|
|
481
|
+
:type="nextHref ? undefined : 'button'"
|
|
482
|
+
:href="nextHref"
|
|
483
|
+
:rel="nextHref ? 'next' : undefined"
|
|
484
|
+
:disabled="nextHref ? undefined : pending || failed || !hasNext"
|
|
441
485
|
class="
|
|
442
486
|
inline-flex h-11 items-center justify-center px-5
|
|
443
487
|
[font-family:inherit] text-xs font-medium uppercase tracking-[0.08em]
|
|
444
|
-
text-orionplace-text
|
|
488
|
+
text-orionplace-text no-underline
|
|
445
489
|
border border-solid border-orionplace-border bg-transparent
|
|
446
490
|
cursor-pointer outline-none
|
|
447
491
|
hover:border-orionplace-text focus-visible:border-orionplace-text
|
|
448
492
|
disabled:cursor-not-allowed disabled:opacity-40
|
|
449
493
|
"
|
|
450
|
-
@click="
|
|
494
|
+
@click="
|
|
495
|
+
nextHref
|
|
496
|
+
? followPage($event, page + 1)
|
|
497
|
+
: emit('changePage', page + 1)
|
|
498
|
+
"
|
|
451
499
|
>
|
|
452
500
|
{{ labels.next }} →
|
|
453
|
-
</
|
|
501
|
+
</component>
|
|
454
502
|
</nav>
|
|
455
503
|
</div>
|
|
456
504
|
</section>
|
|
@@ -40,6 +40,11 @@ pages supplied by the full starter.
|
|
|
40
40
|
between them, for a narrow list such as the support conversations; the buttons
|
|
41
41
|
keep Previous and Next as their accessible names.
|
|
42
42
|
|
|
43
|
+
`linked` is for lists that keep their page number in the address
|
|
44
|
+
(`?page=2`): Previous and Next are links to those addresses, so search engines
|
|
45
|
+
reach every page of the list, and a click still changes the page in place.
|
|
46
|
+
The journal and the store list use it.
|
|
47
|
+
|
|
43
48
|
## Endpoint coverage
|
|
44
49
|
|
|
45
50
|
No direct API request; the parent supplies the state.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref } from 'vue';
|
|
3
|
+
import { useRoute } from '#imports';
|
|
3
4
|
import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
|
|
4
5
|
import { featureLabels } from '@/utils/orionplace/featureLabels';
|
|
5
6
|
import { scrollToPaginatedList } from '@/utils/orionplace/paginationScroll';
|
|
7
|
+
import { storefrontPageHref } from '@/utils/orionplace/pageSeo';
|
|
6
8
|
import type { IApiPagination } from '@/interfaces/orionplace/accounts/IProfileData';
|
|
7
9
|
import '@/components/orionplace/shared/features.css';
|
|
8
10
|
|
|
@@ -11,17 +13,58 @@
|
|
|
11
13
|
pending?: boolean;
|
|
12
14
|
scrollTarget?: HTMLElement | string | null;
|
|
13
15
|
compact?: boolean;
|
|
16
|
+
linked?: boolean;
|
|
14
17
|
}>();
|
|
18
|
+
// `linked`: the list keeps its page number in the address (`?page=`), so
|
|
19
|
+
// previous and next are links to those addresses and search engines reach
|
|
20
|
+
// every page. A click still changes the page in place.
|
|
15
21
|
const emit = defineEmits<{ change: [page: number] }>();
|
|
16
22
|
const selectionStore = useOrionplaceSelectionsStore();
|
|
17
23
|
const labels = computed(() => featureLabels(selectionStore.localeCode));
|
|
18
24
|
const page = computed(() => props.pagination.page ?? 1);
|
|
19
25
|
const nav = ref<HTMLElement>();
|
|
26
|
+
const route = useRoute();
|
|
27
|
+
const previousPage = computed(
|
|
28
|
+
() => props.pagination.previous_page_number || page.value - 1,
|
|
29
|
+
);
|
|
30
|
+
const nextPage = computed(() =>
|
|
31
|
+
typeof props.pagination.next_page_number === 'number'
|
|
32
|
+
? props.pagination.next_page_number
|
|
33
|
+
: page.value + 1,
|
|
34
|
+
);
|
|
35
|
+
const search = computed(
|
|
36
|
+
() => new URL(route.fullPath, 'http://storefront').search,
|
|
37
|
+
);
|
|
38
|
+
const previousHref = computed(() =>
|
|
39
|
+
props.linked && !props.pending && props.pagination.has_previous
|
|
40
|
+
? storefrontPageHref(route.path, search.value, previousPage.value)
|
|
41
|
+
: undefined,
|
|
42
|
+
);
|
|
43
|
+
const nextHref = computed(() =>
|
|
44
|
+
props.linked && !props.pending && props.pagination.has_next
|
|
45
|
+
? storefrontPageHref(route.path, search.value, nextPage.value)
|
|
46
|
+
: undefined,
|
|
47
|
+
);
|
|
20
48
|
|
|
21
49
|
function change(value: number) {
|
|
22
50
|
emit('change', value);
|
|
23
51
|
scrollToPaginatedList(nav.value, props.scrollTarget);
|
|
24
52
|
}
|
|
53
|
+
|
|
54
|
+
// A link opened in a new tab or window keeps its default.
|
|
55
|
+
function follow(event: MouseEvent, value: number) {
|
|
56
|
+
if (
|
|
57
|
+
event.button !== 0 ||
|
|
58
|
+
event.metaKey ||
|
|
59
|
+
event.ctrlKey ||
|
|
60
|
+
event.shiftKey ||
|
|
61
|
+
event.altKey
|
|
62
|
+
) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
change(value);
|
|
67
|
+
}
|
|
25
68
|
</script>
|
|
26
69
|
|
|
27
70
|
<template>
|
|
@@ -41,13 +84,20 @@
|
|
|
41
84
|
The compact form fits a narrow list: arrow buttons named for screen
|
|
42
85
|
readers, with the page count between them.
|
|
43
86
|
-->
|
|
44
|
-
<
|
|
87
|
+
<component
|
|
88
|
+
:is="previousHref ? 'a' : 'button'"
|
|
45
89
|
class="op-feature-button"
|
|
46
|
-
type="button"
|
|
90
|
+
:type="previousHref ? undefined : 'button'"
|
|
91
|
+
:href="previousHref"
|
|
92
|
+
:rel="previousHref ? 'prev' : undefined"
|
|
47
93
|
:aria-label="compact ? labels.previous : undefined"
|
|
48
94
|
:title="compact ? labels.previous : undefined"
|
|
49
|
-
:disabled="pending || !pagination.has_previous"
|
|
50
|
-
@click="
|
|
95
|
+
:disabled="previousHref ? undefined : pending || !pagination.has_previous"
|
|
96
|
+
@click="
|
|
97
|
+
previousHref
|
|
98
|
+
? follow($event, previousPage)
|
|
99
|
+
: change(previousPage)
|
|
100
|
+
"
|
|
51
101
|
>
|
|
52
102
|
<svg
|
|
53
103
|
v-if="compact"
|
|
@@ -63,24 +113,21 @@
|
|
|
63
113
|
<path d="m15 5-7 7 7 7" />
|
|
64
114
|
</svg>
|
|
65
115
|
<template v-else>{{ labels.previous }}</template>
|
|
66
|
-
</
|
|
116
|
+
</component>
|
|
67
117
|
<span class="tabular-nums">
|
|
68
118
|
{{ labels.page }} {{ page }}
|
|
69
119
|
<template v-if="pagination.pages">/ {{ pagination.pages }}</template>
|
|
70
120
|
</span>
|
|
71
|
-
<
|
|
121
|
+
<component
|
|
122
|
+
:is="nextHref ? 'a' : 'button'"
|
|
72
123
|
class="op-feature-button"
|
|
73
|
-
type="button"
|
|
124
|
+
:type="nextHref ? undefined : 'button'"
|
|
125
|
+
:href="nextHref"
|
|
126
|
+
:rel="nextHref ? 'next' : undefined"
|
|
74
127
|
:aria-label="compact ? labels.next : undefined"
|
|
75
128
|
:title="compact ? labels.next : undefined"
|
|
76
|
-
:disabled="pending || !pagination.has_next"
|
|
77
|
-
@click="
|
|
78
|
-
change(
|
|
79
|
-
typeof pagination.next_page_number === 'number'
|
|
80
|
-
? pagination.next_page_number
|
|
81
|
-
: page + 1,
|
|
82
|
-
)
|
|
83
|
-
"
|
|
129
|
+
:disabled="nextHref ? undefined : pending || !pagination.has_next"
|
|
130
|
+
@click="nextHref ? follow($event, nextPage) : change(nextPage)"
|
|
84
131
|
>
|
|
85
132
|
<svg
|
|
86
133
|
v-if="compact"
|
|
@@ -96,6 +143,6 @@
|
|
|
96
143
|
<path d="m9 5 7 7-7 7" />
|
|
97
144
|
</svg>
|
|
98
145
|
<template v-else>{{ labels.next }}</template>
|
|
99
|
-
</
|
|
146
|
+
</component>
|
|
100
147
|
</nav>
|
|
101
148
|
</template>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
storefrontOpenGraphLocale,
|
|
10
10
|
storefrontOpenGraphType,
|
|
11
11
|
storefrontPageMetadata,
|
|
12
|
+
storefrontPagedText,
|
|
12
13
|
storefrontPublicOrigin,
|
|
13
14
|
} from '@/utils/orionplace/pageSeo';
|
|
14
15
|
import type { StorefrontSeoPage } from '@/utils/orionplace/pageSeo';
|
|
@@ -59,19 +60,35 @@ export function useOrionplacePageSeo(
|
|
|
59
60
|
),
|
|
60
61
|
);
|
|
61
62
|
|
|
63
|
+
// A later list page (`?page=2`) names its page number.
|
|
64
|
+
const paged = computed(() => ({
|
|
65
|
+
title:
|
|
66
|
+
storefrontPagedText(metadata.value.title, canonical.value, toValue(locale)) ??
|
|
67
|
+
metadata.value.title,
|
|
68
|
+
description:
|
|
69
|
+
storefrontPagedText(
|
|
70
|
+
metadata.value.description,
|
|
71
|
+
canonical.value,
|
|
72
|
+
toValue(locale),
|
|
73
|
+
) ?? metadata.value.description,
|
|
74
|
+
}));
|
|
75
|
+
|
|
62
76
|
// Link previews use the same title and description as the page; the
|
|
63
77
|
// image is the record's first picture when the page has one, as a
|
|
64
78
|
// complete address.
|
|
65
79
|
const seo = {
|
|
66
|
-
title: () =>
|
|
67
|
-
description: () =>
|
|
80
|
+
title: () => paged.value.title,
|
|
81
|
+
description: () => paged.value.description,
|
|
68
82
|
keywords: () => metadata.value.keywords,
|
|
69
83
|
robots: () => toValue(values.robots),
|
|
70
|
-
ogTitle: () =>
|
|
71
|
-
ogDescription: () =>
|
|
84
|
+
ogTitle: () => paged.value.title,
|
|
85
|
+
ogDescription: () => paged.value.description,
|
|
72
86
|
ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
|
|
73
87
|
ogUrl: () => canonical.value,
|
|
74
|
-
|
|
88
|
+
// Nuxt's head types leave out og:type `product` (Open Graph product
|
|
89
|
+
// pages); the tag renders as given.
|
|
90
|
+
ogType: () =>
|
|
91
|
+
storefrontOpenGraphType(toValue(page)) as 'website' | 'article',
|
|
75
92
|
ogLocale: () => storefrontOpenGraphLocale(toValue(locale)),
|
|
76
93
|
articlePublishedTime: () => toValue(values.published),
|
|
77
94
|
articleModifiedTime: () => toValue(values.modified),
|
|
@@ -96,7 +113,7 @@ export function useOrionplacePageSeo(
|
|
|
96
113
|
{ key: 'canonical', rel: 'canonical', href: canonical.value },
|
|
97
114
|
...alternates.value.map((alternate) => ({
|
|
98
115
|
key: `alternate-${alternate.hreflang}`,
|
|
99
|
-
rel: 'alternate',
|
|
116
|
+
rel: 'alternate' as const,
|
|
100
117
|
hreflang: alternate.hreflang,
|
|
101
118
|
href: alternate.href,
|
|
102
119
|
})),
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
5
5
|
import { useOrionplacePageSeo } from '@/composables/useOrionplacePageSeo';
|
|
6
6
|
import { useOrionplaceServerData } from '@/composables/useOrionplaceServerData';
|
|
7
|
+
import { useOrionplaceMissingPage } from '@/composables/useOrionplaceMissingPage';
|
|
7
8
|
import {
|
|
8
9
|
blogPageNumber,
|
|
9
10
|
blogPageSize,
|
|
11
|
+
journalPagePastEnd,
|
|
10
12
|
blogPostsRequest,
|
|
11
13
|
blogViewType,
|
|
12
14
|
} from '@/utils/orionplace/blogLayout';
|
|
@@ -49,6 +51,11 @@
|
|
|
49
51
|
);
|
|
50
52
|
|
|
51
53
|
useOrionplacePageSeo('journal', () => store.localeCode);
|
|
54
|
+
|
|
55
|
+
// A page number past the last page has no articles: it answers 404, so
|
|
56
|
+
// search engines drop it instead of indexing an empty list.
|
|
57
|
+
const pastLastPage = journalPagePastEnd(page.value, serverPage.value);
|
|
58
|
+
useOrionplaceMissingPage(() => pastLastPage, 'Page not found');
|
|
52
59
|
</script>
|
|
53
60
|
|
|
54
61
|
<template>
|
|
@@ -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=`).
|
|
466
|
-
*
|
|
467
|
-
* pages
|
|
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
|
|
477
|
-
const query
|
|
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
|
|
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:
|
|
659
|
+
): { key: string; rel: 'icon'; href: string }[] {
|
|
609
660
|
const href = storefrontAbsoluteUrl(icon, '');
|
|
610
661
|
return href ? [{ key: 'icon', rel: 'icon', href }] : [];
|
|
611
662
|
}
|