orionplace-svelte 0.7.1 → 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 CHANGED
@@ -519,6 +519,7 @@ data and actions; `orionplace/COMPONENTS.md` lists their customer paths.
519
519
  Page titles, descriptions and link-preview metadata follow the current page
520
520
  and selected language, including client navigation. Public events and banner
521
521
  images come from Application Load.
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.
522
523
 
523
524
  ## Application Load starter
524
525
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-svelte",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -3,9 +3,13 @@ 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
5
  import { useHead, useSeoMeta } from '@/lib/orionplace/navigation.svelte';
6
+ import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
6
7
  import {
7
8
  storefrontAbsoluteUrl,
9
+ storefrontAlternateLinks,
8
10
  storefrontCanonicalUrl,
11
+ storefrontOpenGraphLocale,
12
+ storefrontOpenGraphType,
9
13
  storefrontPageMetadata,
10
14
  storefrontPublicOrigin,
11
15
  } from '@/lib/utils/orionplace/pageSeo';
@@ -21,6 +25,9 @@ export function useOrionplacePageSeo(
21
25
  keywords?: MaybeRefOrGetter<string | undefined>;
22
26
  robots?: MaybeRefOrGetter<string | undefined>;
23
27
  image?: MaybeRefOrGetter<string | undefined>;
28
+ /** A journal article's dates (ISO 8601) for article:published_time. */
29
+ published?: MaybeRefOrGetter<string | undefined>;
30
+ modified?: MaybeRefOrGetter<string | undefined>;
24
31
  /**
25
32
  * The page's schema.org data (a record page, e.g. the product), built
26
33
  * from its canonical address and the visitor's origin. A page without a
@@ -29,6 +36,7 @@ export function useOrionplacePageSeo(
29
36
  structuredData?: (canonical: string, origin: string) => unknown;
30
37
  } = {},
31
38
  ) {
39
+ const store = useOrionplaceStore();
32
40
  // The address the visitor used: the proxy's host during the server render.
33
41
  const origin = browser
34
42
  ? window.location.origin
@@ -62,7 +70,18 @@ export function useOrionplacePageSeo(
62
70
  ogDescription: () => metadata.value.description,
63
71
  ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
64
72
  ogUrl: () => canonical.value,
73
+ ogType: () => storefrontOpenGraphType(toValue(page)),
74
+ ogLocale: () => storefrontOpenGraphLocale(toValue(locale)),
75
+ articlePublishedTime: () => toValue(values.published),
76
+ articleModifiedTime: () => toValue(values.modified),
65
77
  };
78
+ // The page in the storefront's other languages, beside the canonical one.
79
+ const alternates = computed(() =>
80
+ storefrontAlternateLinks(
81
+ canonical.value,
82
+ Object.keys(store.init?.locale ?? {}),
83
+ ),
84
+ );
66
85
  const structured = computed(() =>
67
86
  canonical.value && values.structuredData
68
87
  ? structuredDataJson(values.structuredData(canonical.value, origin))
@@ -70,7 +89,16 @@ export function useOrionplacePageSeo(
70
89
  );
71
90
  useSeoMeta(seo);
72
91
  useHead(() => ({
73
- link: canonical.value ? [{ rel: 'canonical', href: canonical.value }] : [],
92
+ link: canonical.value
93
+ ? [
94
+ { rel: 'canonical', href: canonical.value },
95
+ ...alternates.value.map((alternate) => ({
96
+ rel: 'alternate',
97
+ hreflang: alternate.hreflang,
98
+ href: alternate.href,
99
+ })),
100
+ ]
101
+ : [],
74
102
  jsonLd: structured.value || undefined,
75
103
  }));
76
104
  }
@@ -1,7 +1,11 @@
1
1
  <script lang="ts">
2
2
  import type { PageHead } from '@/lib/orionplace/navigation.svelte';
3
+ import { storefrontIconLinks } from '@/lib/utils/orionplace/pageSeo';
3
4
 
4
- let { metadata }: { metadata: PageHead } = $props();
5
+ // `icon`: the store's icon from App Load (logos.ico).
6
+ let { metadata, icon }: { metadata: PageHead; icon?: string | null } =
7
+ $props();
8
+ const iconLinks = $derived(storefrontIconLinks(icon));
5
9
 
6
10
  // The JSON is escaped by structuredDataJson, so it cannot close the tag.
7
11
  const scriptOpen = '<script type="application/ld+json">';
@@ -13,7 +17,7 @@
13
17
 
14
18
  {#each metadata.meta ?? [] as meta}
15
19
  {#if meta.content}
16
- {#if meta.property || meta.name?.startsWith('og:')}
20
+ {#if meta.property || /^(?:og|article):/.test(meta.name ?? '')}
17
21
  <meta
18
22
  property={meta.property ?? meta.name}
19
23
  content={meta.content}
@@ -31,12 +35,20 @@
31
35
  {@html scriptOpen + metadata.jsonLd + scriptClose}
32
36
  {/if}
33
37
 
34
- {#each metadata.link ?? [] as link (link.rel)}
38
+ {#each metadata.link ?? [] as link (`${link.rel} ${link.hreflang ?? ''}`)}
35
39
  {#if link.href}
36
40
  <link
37
41
  rel={link.rel}
42
+ hreflang={link.hreflang}
38
43
  href={link.href}
39
44
  />
40
45
  {/if}
41
46
  {/each}
47
+
48
+ {#each iconLinks as link (link.key)}
49
+ <link
50
+ rel={link.rel}
51
+ href={link.href}
52
+ />
53
+ {/each}
42
54
  </svelte:head>
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  AppLoadError,
3
+ appLoadRejectedLocale,
3
4
  appLoadResult,
4
5
  inspectAppLoad,
5
6
  } from '@/lib/orionplace/appLoadResult';
@@ -68,6 +69,10 @@ export async function requestBrowserAppLoad(
68
69
  });
69
70
  }
70
71
  const failure = inspectAppLoad(body, status);
72
+ // A language the storefront does not have opens its default language.
73
+ if (failure && locale && appLoadRejectedLocale(failure)) {
74
+ return requestBrowserAppLoad(signal);
75
+ }
71
76
  if (failure) {
72
77
  throw new AppLoadError('Marketplace initialization failed.', failure);
73
78
  }
@@ -108,3 +108,13 @@ export class AppLoadError extends Error {
108
108
  this.data = data;
109
109
  }
110
110
  }
111
+
112
+ /**
113
+ * Whether App Load refused the request's language (`?lang=` of a language the
114
+ * storefront does not have): the backend answers it with field errors and
115
+ * HTTP 452. The language is the only client value App Load reads, so the
116
+ * storefront loads again without it.
117
+ */
118
+ export function appLoadRejectedLocale(failure: AppLoadFailure): boolean {
119
+ return failure.kind === 'validation' || failure.httpStatus === 452;
120
+ }
@@ -1,6 +1,10 @@
1
1
  import { env } from '$env/dynamic/private';
2
2
  import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs';
3
- import { appLoadResult, inspectAppLoad } from '@/lib/orionplace/appLoadResult';
3
+ import {
4
+ appLoadRejectedLocale,
5
+ appLoadResult,
6
+ inspectAppLoad,
7
+ } from '@/lib/orionplace/appLoadResult';
4
8
  import type { OrionplaceSnapshot } from '@/lib/orionplace/appLoadData';
5
9
  import type { AppLoadRequest } from '@/lib/interfaces/orionplace/transport';
6
10
  import type { Cookies, RequestEvent } from '@sveltejs/kit';
@@ -20,9 +24,13 @@ const FORWARDED = [
20
24
  'x-tz',
21
25
  ];
22
26
 
23
- function buildRequest(event: RequestEvent, signal: AbortSignal): Request {
27
+ function buildRequest(
28
+ event: RequestEvent,
29
+ signal: AbortSignal,
30
+ withLocale = true,
31
+ ): Request {
24
32
  const url = new URL('/api/appload', event.url.origin);
25
- const locale = event.url.searchParams.get('locale');
33
+ const locale = withLocale ? event.url.searchParams.get('locale') : null;
26
34
  if (locale) {
27
35
  url.searchParams.set('lang', locale);
28
36
  }
@@ -112,13 +120,14 @@ export interface ServerAppLoad extends OrionplaceSnapshot {
112
120
  export async function loadOrionplaceAppLoad(
113
121
  event: RequestEvent,
114
122
  signal?: AbortSignal,
123
+ withLocale = true,
115
124
  ): Promise<ServerAppLoad> {
116
125
  const timeout = AbortSignal.timeout(35_000);
117
126
  const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
118
127
  let response: Response;
119
128
  try {
120
129
  response = await forwardMarketplace(
121
- buildRequest(event, combined),
130
+ buildRequest(event, combined, withLocale),
122
131
  {
123
132
  mimicFrontDomain: env.MIMIC_FRONT_DOMAIN ?? '',
124
133
  },
@@ -140,6 +149,16 @@ export async function loadOrionplaceAppLoad(
140
149
  body = null;
141
150
  }
142
151
  const failure = inspectAppLoad(body, response.status);
152
+ // An address with a language the storefront does not have (`?locale=xx`)
153
+ // opens in the storefront's default language instead of the 503 page.
154
+ if (
155
+ failure &&
156
+ withLocale &&
157
+ event.url.searchParams.get('locale') &&
158
+ appLoadRejectedLocale(failure)
159
+ ) {
160
+ return loadOrionplaceAppLoad(event, signal, false);
161
+ }
143
162
  if (failure) {
144
163
  return { result: null, failure, cookies };
145
164
  }
@@ -103,8 +103,9 @@ 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
+ // <link> tags, one per `rel` and `hreflang` (the canonical address and
107
+ // the page in other languages).
108
+ link?: { rel: string; href: string; hreflang?: string }[];
108
109
  // The page's schema.org data as JSON (structuredDataJson), if it has any.
109
110
  jsonLd?: string;
110
111
  }
@@ -114,7 +115,7 @@ function collectHead(readers: HeadReader[]): PageHead {
114
115
  let title: string | undefined;
115
116
  let jsonLd: string | undefined;
116
117
  const meta = new Map<string, Record<string, any>>();
117
- const link = new Map<string, { rel: string; href: string }>();
118
+ const link = new Map<string, { rel: string; href: string; hreflang?: string }>();
118
119
  for (const read of readers) {
119
120
  const head = read();
120
121
  title = head.title ?? title;
@@ -126,7 +127,7 @@ function collectHead(readers: HeadReader[]): PageHead {
126
127
  }
127
128
  }
128
129
  for (const tag of head.link ?? []) {
129
- link.set(tag.rel, tag);
130
+ link.set(`${tag.rel} ${tag.hreflang ?? ''}`, tag);
130
131
  }
131
132
  }
132
133
  return {
@@ -180,6 +181,13 @@ export function useHead(source: PageHead | (() => PageHead)) {
180
181
  return () => untrack(() => registry.remove(read));
181
182
  });
182
183
  }
184
+ // The other Open Graph names the pages set (useSeoMeta keys, as in Nuxt).
185
+ const seoMetaNames: Record<string, string> = {
186
+ ogType: 'og:type',
187
+ ogLocale: 'og:locale',
188
+ articlePublishedTime: 'article:published_time',
189
+ articleModifiedTime: 'article:modified_time',
190
+ };
183
191
  export function useSeoMeta(source: Record<string, any>) {
184
192
  useHead(() => ({
185
193
  title: typeof source.title === 'function' ? source.title() : source.title,
@@ -195,7 +203,7 @@ export function useSeoMeta(source: Record<string, any>) {
195
203
  ? 'og:image'
196
204
  : name === 'ogUrl'
197
205
  ? 'og:url'
198
- : name,
206
+ : (seoMetaNames[name] ?? name),
199
207
  content: typeof value === 'function' ? value() : value,
200
208
  })),
201
209
  }));
@@ -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
+ }
@@ -134,7 +134,10 @@
134
134
  </OrionplaceThemeProvider>
135
135
 
136
136
  <!-- Render after the page registers its metadata during server rendering. -->
137
- <StorefrontPageHead metadata={metadata.value} />
137
+ <StorefrontPageHead
138
+ metadata={metadata.value}
139
+ icon={marketplace.init?.logos?.ico}
140
+ />
138
141
 
139
142
  <style>
140
143
  .storefront-progress {
@@ -7,6 +7,11 @@
7
7
  import { useOrionplaceStorefront } from '@/lib/composables/useOrionplaceStorefront.svelte';
8
8
  import { useOrionplaceServerData } from '@/lib/composables/useOrionplaceServerData.svelte';
9
9
  import { requestBlogPost } from '@/lib/utils/orionplace/blogLayout';
10
+ import {
11
+ storefrontAbsoluteUrl,
12
+ storefrontArticleData,
13
+ storefrontIsoDate,
14
+ } from '@/lib/utils/orionplace/pageSeo';
10
15
  import {
11
16
  getAvailableNestedValueFromObject,
12
17
  plainText,
@@ -77,6 +82,37 @@
77
82
  ),
78
83
  ).slice(0, 160),
79
84
  image: () => cover.value,
85
+ published: () => storefrontIsoDate(post.value?.date_created?.date),
86
+ modified: () =>
87
+ storefrontIsoDate(post.value?.date_updated?.date) ??
88
+ storefrontIsoDate(post.value?.date_created?.date),
89
+ // The article as schema.org BlogPosting, with the store as its author.
90
+ structuredData: (canonical, origin) =>
91
+ post.value
92
+ ? storefrontArticleData({
93
+ canonical,
94
+ origin,
95
+ headline:
96
+ getAvailableNestedValueFromObject(
97
+ post.value.locale,
98
+ store.localeCode,
99
+ 'post_title',
100
+ ) ?? '',
101
+ description:
102
+ plainText(
103
+ getAvailableNestedValueFromObject(
104
+ post.value.locale,
105
+ store.localeCode,
106
+ 'post_preview_text',
107
+ ),
108
+ ).slice(0, 160) || undefined,
109
+ image: storefrontAbsoluteUrl(cover.value, origin),
110
+ published: storefrontIsoDate(post.value.date_created?.date),
111
+ modified: storefrontIsoDate(post.value.date_updated?.date),
112
+ locale: store.localeCode,
113
+ storeName: store.init?.contact?.company_name,
114
+ })
115
+ : undefined,
80
116
  });
81
117
 
82
118
  // An invalid ID, or an article that blog_posts_get does not return,