orionplace-nuxt 0.7.2 → 0.7.3
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 -1
- package/package.json +1 -1
- package/src/favicon-handler.mjs +12 -0
- package/src/module.mjs +1 -0
- package/templates/app/app.vue +4 -0
- package/templates/app/composables/useOrionplaceAppLoad.ts +10 -1
- package/templates/app/composables/useOrionplacePageSeo.ts +28 -1
- package/templates/app/pages/journal/[id].vue +36 -0
- package/templates/app/utils/orionplace/pageSeo.ts +261 -2
- package/templates/app/utils/orionplaceAppLoad.ts +10 -0
package/README.md
CHANGED
|
@@ -502,11 +502,12 @@ server from the project root or explicitly point it at the configuration directo
|
|
|
502
502
|
Every supplied page sets its title, description and keywords through
|
|
503
503
|
`app/composables/useOrionplacePageSeo.ts` and Nuxt's `useSeoMeta()`.
|
|
504
504
|
The homepage prefers `init.seo`, and category pages prefer their localized SEO
|
|
505
|
-
fields. Empty values fall back to page-specific Russian or English copy in
|
|
505
|
+
fields. Empty values fall back to page-specific Russian, Spanish or English copy in
|
|
506
506
|
`app/utils/orionplace/pageSeo.ts`. Metadata updates with the current locale and
|
|
507
507
|
route data. The homepage retains additional backend Open Graph and Twitter tags
|
|
508
508
|
through `useHead()`. Profile and logout pages remain excluded from indexing;
|
|
509
509
|
search results retain their `noindex, follow` policy.
|
|
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.
|
|
510
511
|
|
|
511
512
|
## Documentation
|
|
512
513
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-nuxt",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineEventHandler, setResponseStatus } from 'h3';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* /favicon.ico without a file in public/: Nitro would answer it with a
|
|
5
|
+
* placeholder text that is not an image. The storefront's icon is the
|
|
6
|
+
* <link rel="icon"> from App Load (logos.ico), so this address answers 404,
|
|
7
|
+
* as in the React and Svelte storefronts. A public/favicon.ico still wins.
|
|
8
|
+
*/
|
|
9
|
+
export default defineEventHandler((event) => {
|
|
10
|
+
setResponseStatus(event, 404);
|
|
11
|
+
return '';
|
|
12
|
+
});
|
package/src/module.mjs
CHANGED
|
@@ -81,6 +81,7 @@ export default defineNuxtModule({
|
|
|
81
81
|
addServerHandler({ route: '/robots.txt', method: 'get', handler: resolve(directory, 'seo.mjs') });
|
|
82
82
|
addServerHandler({ route: '/sitemap.xml', method: 'get', handler: resolve(directory, 'seo.mjs') });
|
|
83
83
|
}
|
|
84
|
+
addServerHandler({ route: '/favicon.ico', method: 'get', handler: resolve(import.meta.dirname, 'favicon-handler.mjs') });
|
|
84
85
|
addServerHandler({ middleware: true, handler: resolve(directory, 'domain.mjs') });
|
|
85
86
|
addServerPlugin(resolve(import.meta.dirname, 'asset-loading-plugin.mjs'));
|
|
86
87
|
},
|
package/templates/app/app.vue
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
import OrionplaceThemeProvider from '@/components/orionplace/theme/OrionplaceThemeProvider.vue';
|
|
7
7
|
import { useOrionplaceTheme } from '@/composables/useOrionplaceTheme';
|
|
8
8
|
import { storefrontPageError } from '@/composables/useOrionplaceMissingPage';
|
|
9
|
+
import { storefrontIconLinks } from '@/utils/orionplace/pageSeo';
|
|
9
10
|
|
|
10
11
|
const store = useOrionplaceStore();
|
|
11
12
|
const route = useRoute();
|
|
12
13
|
const { mode } = useOrionplaceTheme();
|
|
13
14
|
|
|
15
|
+
// The store's icon from the admin panel (logos.ico) for browser tabs and
|
|
16
|
+
// search results; a page without it keeps the browser default.
|
|
14
17
|
useHead(() => ({
|
|
15
18
|
htmlAttrs: { lang: store.localeCode },
|
|
19
|
+
link: storefrontIconLinks(store.init?.logos?.ico),
|
|
16
20
|
}));
|
|
17
21
|
|
|
18
22
|
// The middleware opens the 503 error page during navigation. A refresh that
|
|
@@ -7,7 +7,11 @@ import type {
|
|
|
7
7
|
AppLoadResult,
|
|
8
8
|
} from '@/interfaces/orionplace/transport';
|
|
9
9
|
import { useOrionplaceApi } from '@/composables/useOrionplaceApi';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
appLoadRejectedLocale,
|
|
12
|
+
appLoadResult,
|
|
13
|
+
inspectAppLoad,
|
|
14
|
+
} from '@/utils/orionplaceAppLoad';
|
|
11
15
|
|
|
12
16
|
/** Transport and explicit actions. Calling this accessor does not start a request. */
|
|
13
17
|
export function useOrionplaceAppLoad() {
|
|
@@ -51,6 +55,11 @@ export function useOrionplaceAppLoad() {
|
|
|
51
55
|
// or CSRF token) is relayed to the browser by the shared API transport,
|
|
52
56
|
// which also sends it with the later requests of the same render.
|
|
53
57
|
const failure = inspectAppLoad(received.body, received.status);
|
|
58
|
+
// An address with a language the storefront does not have (`?locale=xx`)
|
|
59
|
+
// opens in the storefront's default language instead of the 503 page.
|
|
60
|
+
if (failure && locale && appLoadRejectedLocale(failure)) {
|
|
61
|
+
return request(signal);
|
|
62
|
+
}
|
|
54
63
|
if (failure) {
|
|
55
64
|
throw createError({
|
|
56
65
|
message: 'Marketplace initialization failed.',
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { computed, toValue } from 'vue';
|
|
2
2
|
import type { MaybeRefOrGetter } from 'vue';
|
|
3
3
|
import { useHead, useRequestHeader, useRoute, useSeoMeta } from '#imports';
|
|
4
|
+
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
4
5
|
import {
|
|
5
6
|
storefrontAbsoluteUrl,
|
|
7
|
+
storefrontAlternateLinks,
|
|
6
8
|
storefrontCanonicalUrl,
|
|
9
|
+
storefrontOpenGraphLocale,
|
|
10
|
+
storefrontOpenGraphType,
|
|
7
11
|
storefrontPageMetadata,
|
|
8
12
|
storefrontPublicOrigin,
|
|
9
13
|
} from '@/utils/orionplace/pageSeo';
|
|
@@ -19,6 +23,9 @@ export function useOrionplacePageSeo(
|
|
|
19
23
|
keywords?: MaybeRefOrGetter<string | undefined>;
|
|
20
24
|
robots?: MaybeRefOrGetter<string | undefined>;
|
|
21
25
|
image?: MaybeRefOrGetter<string | undefined>;
|
|
26
|
+
/** A journal article's dates (ISO 8601) for article:published_time. */
|
|
27
|
+
published?: MaybeRefOrGetter<string | undefined>;
|
|
28
|
+
modified?: MaybeRefOrGetter<string | undefined>;
|
|
22
29
|
/**
|
|
23
30
|
* The page's schema.org data (a record page, e.g. the product), built
|
|
24
31
|
* from its canonical address and the visitor's origin. A page without a
|
|
@@ -28,6 +35,7 @@ export function useOrionplacePageSeo(
|
|
|
28
35
|
} = {},
|
|
29
36
|
) {
|
|
30
37
|
const route = useRoute();
|
|
38
|
+
const store = useOrionplaceStore();
|
|
31
39
|
// The address the visitor used: the proxy's host during the server render.
|
|
32
40
|
const origin = import.meta.server
|
|
33
41
|
? storefrontPublicOrigin(
|
|
@@ -63,7 +71,18 @@ export function useOrionplacePageSeo(
|
|
|
63
71
|
ogDescription: () => metadata.value.description,
|
|
64
72
|
ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
|
|
65
73
|
ogUrl: () => canonical.value,
|
|
74
|
+
ogType: () => storefrontOpenGraphType(toValue(page)),
|
|
75
|
+
ogLocale: () => storefrontOpenGraphLocale(toValue(locale)),
|
|
76
|
+
articlePublishedTime: () => toValue(values.published),
|
|
77
|
+
articleModifiedTime: () => toValue(values.modified),
|
|
66
78
|
};
|
|
79
|
+
// The page in the storefront's other languages, beside the canonical one.
|
|
80
|
+
const alternates = computed(() =>
|
|
81
|
+
storefrontAlternateLinks(
|
|
82
|
+
canonical.value,
|
|
83
|
+
Object.keys(store.init?.locale ?? {}),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
67
86
|
const structured = computed(() =>
|
|
68
87
|
canonical.value && values.structuredData
|
|
69
88
|
? structuredDataJson(values.structuredData(canonical.value, origin))
|
|
@@ -73,7 +92,15 @@ export function useOrionplacePageSeo(
|
|
|
73
92
|
useHead({
|
|
74
93
|
link: () =>
|
|
75
94
|
canonical.value
|
|
76
|
-
? [
|
|
95
|
+
? [
|
|
96
|
+
{ key: 'canonical', rel: 'canonical', href: canonical.value },
|
|
97
|
+
...alternates.value.map((alternate) => ({
|
|
98
|
+
key: `alternate-${alternate.hreflang}`,
|
|
99
|
+
rel: 'alternate',
|
|
100
|
+
hreflang: alternate.hreflang,
|
|
101
|
+
href: alternate.href,
|
|
102
|
+
})),
|
|
103
|
+
]
|
|
77
104
|
: [],
|
|
78
105
|
script: () =>
|
|
79
106
|
structured.value
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
import { useOrionplaceStorefront } from '@/composables/useOrionplaceStorefront';
|
|
8
8
|
import { useOrionplaceServerData } from '@/composables/useOrionplaceServerData';
|
|
9
9
|
import { requestBlogPost } from '@/utils/orionplace/blogLayout';
|
|
10
|
+
import {
|
|
11
|
+
storefrontAbsoluteUrl,
|
|
12
|
+
storefrontArticleData,
|
|
13
|
+
storefrontIsoDate,
|
|
14
|
+
} from '@/utils/orionplace/pageSeo';
|
|
10
15
|
import {
|
|
11
16
|
getAvailableNestedValueFromObject,
|
|
12
17
|
plainText,
|
|
@@ -74,6 +79,37 @@
|
|
|
74
79
|
),
|
|
75
80
|
).slice(0, 160),
|
|
76
81
|
image: () => cover.value,
|
|
82
|
+
published: () => storefrontIsoDate(post.value?.date_created?.date),
|
|
83
|
+
modified: () =>
|
|
84
|
+
storefrontIsoDate(post.value?.date_updated?.date) ??
|
|
85
|
+
storefrontIsoDate(post.value?.date_created?.date),
|
|
86
|
+
// The article as schema.org BlogPosting, with the store as its author.
|
|
87
|
+
structuredData: (canonical, origin) =>
|
|
88
|
+
post.value
|
|
89
|
+
? storefrontArticleData({
|
|
90
|
+
canonical,
|
|
91
|
+
origin,
|
|
92
|
+
headline:
|
|
93
|
+
getAvailableNestedValueFromObject(
|
|
94
|
+
post.value.locale,
|
|
95
|
+
store.localeCode,
|
|
96
|
+
'post_title',
|
|
97
|
+
) ?? '',
|
|
98
|
+
description:
|
|
99
|
+
plainText(
|
|
100
|
+
getAvailableNestedValueFromObject(
|
|
101
|
+
post.value.locale,
|
|
102
|
+
store.localeCode,
|
|
103
|
+
'post_preview_text',
|
|
104
|
+
),
|
|
105
|
+
).slice(0, 160) || undefined,
|
|
106
|
+
image: storefrontAbsoluteUrl(cover.value, origin),
|
|
107
|
+
published: storefrontIsoDate(post.value.date_created?.date),
|
|
108
|
+
modified: storefrontIsoDate(post.value.date_updated?.date),
|
|
109
|
+
locale: store.localeCode,
|
|
110
|
+
storeName: store.init?.contact?.company_name,
|
|
111
|
+
})
|
|
112
|
+
: undefined,
|
|
77
113
|
});
|
|
78
114
|
|
|
79
115
|
// An invalid ID, or an article that blog_posts_get does not return,
|
|
@@ -263,17 +263,157 @@ const pageCopy = {
|
|
|
263
263
|
'error, store',
|
|
264
264
|
],
|
|
265
265
|
},
|
|
266
|
+
es: {
|
|
267
|
+
tariffs: [
|
|
268
|
+
'Planes de suscripción',
|
|
269
|
+
'Precios por periodo, periodos de prueba y opciones de cada plan.',
|
|
270
|
+
'planes, suscripciones, prueba',
|
|
271
|
+
],
|
|
272
|
+
tariff: [
|
|
273
|
+
'Plan de suscripción',
|
|
274
|
+
'Descripción, funciones, periodos y opciones de un plan de suscripción.',
|
|
275
|
+
'plan, suscripción, periodo',
|
|
276
|
+
],
|
|
277
|
+
subscriptions: [
|
|
278
|
+
'Mis suscripciones',
|
|
279
|
+
'Fechas de las suscripciones y renovación automática.',
|
|
280
|
+
'suscripciones, cuenta',
|
|
281
|
+
],
|
|
282
|
+
sellers: ['Vendedores', 'Vendedores y sus productos.', 'vendedores, productos'],
|
|
283
|
+
stores: [
|
|
284
|
+
'Tiendas',
|
|
285
|
+
'Direcciones, contactos y horarios de las tiendas.',
|
|
286
|
+
'tiendas, direcciones',
|
|
287
|
+
],
|
|
288
|
+
catalog: ['Catálogo', 'Categorías de productos de la tienda.', 'catálogo, categorías'],
|
|
289
|
+
activity: [
|
|
290
|
+
'Mi actividad',
|
|
291
|
+
'Códigos promocionales, reseñas e historial de accesos del cliente.',
|
|
292
|
+
'cuenta, códigos promocionales, reseñas, accesos',
|
|
293
|
+
],
|
|
294
|
+
promo: [
|
|
295
|
+
'Productos del código promocional',
|
|
296
|
+
'Productos de la tienda a los que se aplica la oferta del código promocional.',
|
|
297
|
+
'código promocional, oferta, productos',
|
|
298
|
+
],
|
|
299
|
+
wishlist: [
|
|
300
|
+
'Favoritos',
|
|
301
|
+
'Productos guardados y avisos de disponibilidad.',
|
|
302
|
+
'favoritos, productos',
|
|
303
|
+
],
|
|
304
|
+
contact: [
|
|
305
|
+
'Contacto',
|
|
306
|
+
'Datos de contacto de la empresa y formulario de mensajes de la tienda.',
|
|
307
|
+
'contacto, mensaje',
|
|
308
|
+
],
|
|
309
|
+
privacy: [
|
|
310
|
+
'Política de privacidad',
|
|
311
|
+
'Qué datos recibe la tienda, para qué, a quién los transmite y qué cookies usa el sitio.',
|
|
312
|
+
'datos personales, privacidad, cookies',
|
|
313
|
+
],
|
|
314
|
+
unsubscribe: [
|
|
315
|
+
'Preferencias de correo',
|
|
316
|
+
'Darse de baja de los correos de la tienda.',
|
|
317
|
+
'correos, baja',
|
|
318
|
+
],
|
|
319
|
+
tickets: [
|
|
320
|
+
'Soporte',
|
|
321
|
+
'Conversaciones del usuario con el soporte.',
|
|
322
|
+
'soporte, conversaciones',
|
|
323
|
+
],
|
|
324
|
+
orders: [
|
|
325
|
+
'Mis pedidos',
|
|
326
|
+
'Historial de pedidos y artículos comprados.',
|
|
327
|
+
'pedidos, compras',
|
|
328
|
+
],
|
|
329
|
+
cart: ['Carrito', 'Artículos y pago del pedido.', 'carrito, pago'],
|
|
330
|
+
addresses: [
|
|
331
|
+
'Mis direcciones',
|
|
332
|
+
'Direcciones de entrega guardadas.',
|
|
333
|
+
'direcciones, entrega',
|
|
334
|
+
],
|
|
335
|
+
wallet: [
|
|
336
|
+
'Monedero y pagos',
|
|
337
|
+
'Saldo e historial de pagos.',
|
|
338
|
+
'monedero, pagos',
|
|
339
|
+
],
|
|
340
|
+
home: [
|
|
341
|
+
'Tienda',
|
|
342
|
+
'Categorías y productos de la tienda.',
|
|
343
|
+
'tienda, catálogo, productos',
|
|
344
|
+
],
|
|
345
|
+
browse: [
|
|
346
|
+
'Resultados de búsqueda',
|
|
347
|
+
'Productos que coinciden con la búsqueda y los filtros elegidos.',
|
|
348
|
+
'búsqueda de productos, filtros, catálogo',
|
|
349
|
+
],
|
|
350
|
+
featured: [
|
|
351
|
+
'Destacados',
|
|
352
|
+
'Productos de las categorías que el cliente ha buscado en la tienda.',
|
|
353
|
+
'recomendaciones, productos, filtros',
|
|
354
|
+
],
|
|
355
|
+
category: [
|
|
356
|
+
'Categoría de productos',
|
|
357
|
+
'Productos y filtros disponibles de la categoría elegida.',
|
|
358
|
+
'categoría, productos, filtros',
|
|
359
|
+
],
|
|
360
|
+
journal: [
|
|
361
|
+
'Revista',
|
|
362
|
+
'Publicaciones de la tienda con títulos, resúmenes y tiempo de lectura.',
|
|
363
|
+
'revista, publicaciones, artículos',
|
|
364
|
+
],
|
|
365
|
+
publication: [
|
|
366
|
+
'Publicación de la revista',
|
|
367
|
+
'Un artículo de la revista de la tienda.',
|
|
368
|
+
'revista, publicación, artículo',
|
|
369
|
+
],
|
|
370
|
+
product: [
|
|
371
|
+
'Ficha de producto',
|
|
372
|
+
'Descripción, precio, variantes y disponibilidad del producto.',
|
|
373
|
+
'producto, precio, disponibilidad',
|
|
374
|
+
],
|
|
375
|
+
manufacturers: [
|
|
376
|
+
'Fabricantes',
|
|
377
|
+
'Fabricantes de la tienda con logotipos e índice alfabético.',
|
|
378
|
+
'fabricantes, marcas, índice alfabético',
|
|
379
|
+
],
|
|
380
|
+
profile: [
|
|
381
|
+
'Perfil personal',
|
|
382
|
+
'Datos personales, correo, notificaciones y contraseña de la cuenta.',
|
|
383
|
+
'perfil, cuenta, ajustes',
|
|
384
|
+
],
|
|
385
|
+
logout: [
|
|
386
|
+
'Cerrar sesión',
|
|
387
|
+
'Fin de la sesión y vuelta a la página principal.',
|
|
388
|
+
'cuenta, cerrar sesión, sesión',
|
|
389
|
+
],
|
|
390
|
+
notFound: [
|
|
391
|
+
'Página no encontrada',
|
|
392
|
+
'No hay ninguna página de la tienda en esta dirección.',
|
|
393
|
+
'página no encontrada, 404',
|
|
394
|
+
],
|
|
395
|
+
error: [
|
|
396
|
+
'Algo salió mal',
|
|
397
|
+
'No se pudo mostrar la página de la tienda.',
|
|
398
|
+
'error, tienda',
|
|
399
|
+
],
|
|
400
|
+
},
|
|
266
401
|
} as const;
|
|
267
402
|
|
|
268
403
|
export type StorefrontSeoPage = keyof typeof pageCopy.en;
|
|
269
404
|
|
|
405
|
+
/** The page copy for a storefront language; languages without one use English. */
|
|
406
|
+
function storefrontCopyLanguage(locale: string): keyof typeof pageCopy {
|
|
407
|
+
const language = locale.toLowerCase().split(/[-_]/)[0];
|
|
408
|
+
return language === 'ru' || language === 'es' ? language : 'en';
|
|
409
|
+
}
|
|
410
|
+
|
|
270
411
|
export function storefrontPageMetadata(
|
|
271
412
|
page: StorefrontSeoPage,
|
|
272
413
|
locale: string,
|
|
273
414
|
values: { title?: string; description?: string; keywords?: string } = {},
|
|
274
415
|
) {
|
|
275
|
-
const copy =
|
|
276
|
-
pageCopy[locale.toLowerCase().startsWith('ru') ? 'ru' : 'en'][page];
|
|
416
|
+
const copy = pageCopy[storefrontCopyLanguage(locale)][page];
|
|
277
417
|
const title = plainText(values.title ?? '') || copy[0];
|
|
278
418
|
|
|
279
419
|
return {
|
|
@@ -350,3 +490,122 @@ export function storefrontCanonicalUrl(
|
|
|
350
490
|
}
|
|
351
491
|
return url.toString();
|
|
352
492
|
}
|
|
493
|
+
|
|
494
|
+
/** og:type of a page: a product and a journal article have their own kinds. */
|
|
495
|
+
export function storefrontOpenGraphType(
|
|
496
|
+
page: StorefrontSeoPage,
|
|
497
|
+
): 'product' | 'article' | 'website' {
|
|
498
|
+
if (page === 'product') {
|
|
499
|
+
return 'product';
|
|
500
|
+
}
|
|
501
|
+
return page === 'publication' ? 'article' : 'website';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const openGraphRegions: Record<string, string> = {
|
|
505
|
+
de: 'de_DE',
|
|
506
|
+
en: 'en_US',
|
|
507
|
+
es: 'es_ES',
|
|
508
|
+
fr: 'fr_FR',
|
|
509
|
+
it: 'it_IT',
|
|
510
|
+
kk: 'kk_KZ',
|
|
511
|
+
pl: 'pl_PL',
|
|
512
|
+
pt: 'pt_PT',
|
|
513
|
+
ru: 'ru_RU',
|
|
514
|
+
tr: 'tr_TR',
|
|
515
|
+
uk: 'uk_UA',
|
|
516
|
+
zh: 'zh_CN',
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/** og:locale (`language_TERRITORY`) of a storefront language, when it is known. */
|
|
520
|
+
export function storefrontOpenGraphLocale(locale: string): string | undefined {
|
|
521
|
+
return openGraphRegions[locale.toLowerCase().split(/[-_]/)[0]!];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The same page in every storefront language (hreflang), for a page with a
|
|
526
|
+
* canonical address: the canonical address with each language's `?locale=`,
|
|
527
|
+
* as the sitemap lists them. Search engines that ignore the sitemap's
|
|
528
|
+
* language links (Yandex) read these. A single language gives none.
|
|
529
|
+
*/
|
|
530
|
+
export function storefrontAlternateLinks(
|
|
531
|
+
canonical: string | undefined,
|
|
532
|
+
locales: string[],
|
|
533
|
+
): { hreflang: string; href: string }[] {
|
|
534
|
+
const codes = [
|
|
535
|
+
...new Set(
|
|
536
|
+
locales
|
|
537
|
+
.map((code) => code.trim())
|
|
538
|
+
.filter((code) => /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(code)),
|
|
539
|
+
),
|
|
540
|
+
];
|
|
541
|
+
if (!canonical || codes.length < 2) {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
return codes.map((code) => {
|
|
545
|
+
const url = new URL(canonical);
|
|
546
|
+
url.searchParams.set('locale', code);
|
|
547
|
+
return { hreflang: code, href: url.toString() };
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* A backend date (`d.m.Y`, e.g. `22.09.2026`) as an ISO 8601 date
|
|
553
|
+
* (`2026-09-22`); anything else gives `undefined`. A date without a time
|
|
554
|
+
* cannot disagree with the visible date in another time zone.
|
|
555
|
+
*/
|
|
556
|
+
export function storefrontIsoDate(
|
|
557
|
+
value: string | null | undefined,
|
|
558
|
+
): string | undefined {
|
|
559
|
+
const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec((value ?? '').trim());
|
|
560
|
+
return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* schema.org BlogPosting of a journal article (Google "Article" structured
|
|
565
|
+
* data): headline, cover, dates, and the store as author and publisher.
|
|
566
|
+
*/
|
|
567
|
+
export function storefrontArticleData(input: {
|
|
568
|
+
canonical: string;
|
|
569
|
+
origin: string;
|
|
570
|
+
headline: string;
|
|
571
|
+
description?: string;
|
|
572
|
+
image?: string;
|
|
573
|
+
published?: string;
|
|
574
|
+
modified?: string;
|
|
575
|
+
locale: string;
|
|
576
|
+
storeName?: string;
|
|
577
|
+
}): Record<string, unknown> {
|
|
578
|
+
const store = {
|
|
579
|
+
'@type': 'Organization',
|
|
580
|
+
name: input.storeName || new URL(input.origin).hostname,
|
|
581
|
+
url: `${input.origin}/`,
|
|
582
|
+
};
|
|
583
|
+
const modified = input.modified || input.published;
|
|
584
|
+
return {
|
|
585
|
+
'@context': 'https://schema.org',
|
|
586
|
+
'@type': 'BlogPosting',
|
|
587
|
+
'@id': `${input.canonical}#article`,
|
|
588
|
+
mainEntityOfPage: input.canonical,
|
|
589
|
+
url: input.canonical,
|
|
590
|
+
headline: input.headline.slice(0, 110),
|
|
591
|
+
...(input.description ? { description: input.description } : {}),
|
|
592
|
+
...(input.image ? { image: [input.image] } : {}),
|
|
593
|
+
...(input.published ? { datePublished: input.published } : {}),
|
|
594
|
+
...(modified ? { dateModified: modified } : {}),
|
|
595
|
+
inLanguage: input.locale,
|
|
596
|
+
author: store,
|
|
597
|
+
publisher: store,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The store's icon (App Load `logos.ico`) as `<link rel="icon">` tags: search
|
|
603
|
+
* engines read the icon from the home page. Only a complete http(s) address
|
|
604
|
+
* is used; the type is left out, so it cannot disagree with the file.
|
|
605
|
+
*/
|
|
606
|
+
export function storefrontIconLinks(
|
|
607
|
+
icon: string | null | undefined,
|
|
608
|
+
): { key: string; rel: string; href: string }[] {
|
|
609
|
+
const href = storefrontAbsoluteUrl(icon, '');
|
|
610
|
+
return href ? [{ key: 'icon', rel: 'icon', href }] : [];
|
|
611
|
+
}
|
|
@@ -71,3 +71,13 @@ export function appLoadResult(
|
|
|
71
71
|
loadedAt: new Date().toISOString(),
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether App Load refused the request's language (`?lang=` of a language the
|
|
77
|
+
* storefront does not have): the backend answers it with field errors and
|
|
78
|
+
* HTTP 452. The language is the only client value App Load reads, so the
|
|
79
|
+
* storefront loads again without it.
|
|
80
|
+
*/
|
|
81
|
+
export function appLoadRejectedLocale(failure: AppLoadFailure): boolean {
|
|
82
|
+
return failure.kind === 'validation' || failure.httpStatus === 452;
|
|
83
|
+
}
|