orionplace-nuxt 0.4.0 → 0.6.0

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.
Files changed (35) hide show
  1. package/README.md +19 -4
  2. package/bin/orionplace-nuxt.mjs +7 -0
  3. package/package.json +1 -1
  4. package/runtime/marketplace.mjs +490 -3
  5. package/runtime/seo.mjs +17 -0
  6. package/src/module.mjs +6 -0
  7. package/src/project-readme.mjs +22 -5
  8. package/src/setup.mjs +2 -1
  9. package/src/sitemap.mjs +93 -0
  10. package/templates/app/components/orionplace/account/ProfileDetailsForm.vue +12 -2
  11. package/templates/app/components/orionplace/blog/BlogArticle.README.md +1 -1
  12. package/templates/app/components/orionplace/blog/BlogArticle.vue +78 -1
  13. package/templates/app/components/orionplace/cart/CartCheckoutDetails.README.md +4 -0
  14. package/templates/app/components/orionplace/cart/CartCheckoutDetails.vue +10 -0
  15. package/templates/app/components/orionplace/legal/CookieNotice.README.md +34 -0
  16. package/templates/app/components/orionplace/legal/CookieNotice.vue +84 -0
  17. package/templates/app/components/orionplace/legal/StorefrontPrivacy.README.md +35 -0
  18. package/templates/app/components/orionplace/legal/StorefrontPrivacy.vue +124 -0
  19. package/templates/app/components/orionplace/products/ProductReviewImageViewer.README.md +3 -2
  20. package/templates/app/components/orionplace/products/ProductReviewImageViewer.vue +85 -11
  21. package/templates/app/components/orionplace/storefront/StorefrontFooter.vue +12 -0
  22. package/templates/app/interfaces/orionplace/accounts/IProfileData.ts +2 -0
  23. package/templates/app/interfaces/orionplace/appBase/IApploadUserProfile.ts +2 -0
  24. package/templates/app/layouts/default.vue +7 -0
  25. package/templates/app/pages/privacy.vue +17 -0
  26. package/templates/app/utils/orionplace/blogImageViewer.ts +84 -0
  27. package/templates/app/utils/orionplace/blogLabels.ts +4 -0
  28. package/templates/app/utils/orionplace/cartLabels.ts +2 -0
  29. package/templates/app/utils/orionplace/footerLabels.ts +2 -0
  30. package/templates/app/utils/orionplace/pageSeo.ts +10 -0
  31. package/templates/app/utils/orionplace/privacyLabels.ts +198 -0
  32. package/templates/app/utils/orionplace/profile.ts +12 -0
  33. package/templates/app/utils/orionplace/profileLabels.ts +2 -0
  34. package/templates/app/utils/orionplace/reviewLabels.ts +4 -0
  35. package/templates/orionplace/COMPONENTS.md +8 -2
package/README.md CHANGED
@@ -8,7 +8,7 @@ account, journal and support, in English and Russian. The package includes a Nux
8
8
  setup CLI and the full storefront source, so every page and component in your
9
9
  project is yours to change.
10
10
 
11
- - **Setup CLI:** `init`, `set backend`, `add frontend`, `set mimic` and `update`.
11
+ - **Setup CLI:** `init`, `set backend`, `add frontend`, `set mimic`, `sitemap rebuild` and `update`.
12
12
  - **Server install:** `install` builds the storefront on your Linux server and
13
13
  runs it as a system service behind nginx with HTTPS.
14
14
  - **API gateway:** the browser calls `/api/...` on your own domain; the server
@@ -129,7 +129,7 @@ Framework imports such as `#imports` keep their own names.
129
129
  <!-- components:start (generated by scripts/generate-package-readme-components.mjs) -->
130
130
  ## Components
131
131
 
132
- The starter includes **124 storefront components**, copied into
132
+ The starter includes **126 storefront components**, copied into
133
133
  `app/components/orionplace/` of your project. Each one has a README in English and Russian
134
134
  next to its source that describes its inputs, data and actions.
135
135
 
@@ -294,6 +294,8 @@ Journal content and customer contact flows.
294
294
  | **Contact details** | `AboutPage` | Display the store’s published company and contact information. |
295
295
  | **Contact form** | `ContactForm` | Sending a question to the store. |
296
296
  | **Unsubscribe from emails** | `EmailUnsubscribe` | Confirming an unsubscribe request from an email link. |
297
+ | **Privacy policy page** | `StorefrontPrivacy` | The store’s personal data policy with a cookies section and the operator’s details. |
298
+ | **Cookie notice** | `CookieNotice` | A small notice about the necessary cookies with a link to the cookies section of the policy. |
297
299
 
298
300
  ### Shared interface
299
301
 
@@ -318,6 +320,8 @@ npx orionplace-nuxt add frontend shop.example.com
318
320
  npx orionplace-nuxt add frontend www.shop.example.com
319
321
  npx orionplace-nuxt remove frontend www.shop.example.com
320
322
  npx orionplace-nuxt set mimic shop.example.com
323
+ npx orionplace-nuxt sitemap rebuild [shop.example.com]
324
+ npx orionplace-nuxt sitemap status
321
325
  sudo npx orionplace-nuxt install
322
326
  npx orionplace-nuxt status
323
327
  sudo npx orionplace-nuxt restart
@@ -334,8 +338,15 @@ npm run orionplace:remove-frontend -- www.shop.example.com
334
338
  npm run orionplace:mimic -- shop.example.com
335
339
  npm run orionplace:help
336
340
  npm run orionplace:update
341
+ npm run orionplace:sitemap
337
342
  ```
338
343
 
344
+ The storefront serves `/robots.txt` and `/sitemap.xml` (public pages, categories,
345
+ products and journal publications in every language). The sitemap is rebuilt
346
+ from the backend once an hour; `npm run orionplace:sitemap` rebuilds it at once,
347
+ for example after new publications, and the running storefront serves it without
348
+ a restart.
349
+
339
350
  `npm set` belongs to npm's own configuration system; use the commands above.
340
351
  The setup commands configure local integration files. They do not provision a
341
352
  backend, change your account or create DNS records. `install` deploys the
@@ -1096,10 +1107,14 @@ red `*` directly after the label text.
1096
1107
 
1097
1108
  The default layout renders the [footer](https://orionmarket.place/en/sandbox/components/nuxt/footer)
1098
1109
  with real store categories, home-section anchors, profile/journal navigation,
1099
- a contact-page link and social links. On `/contact`, `StorefrontContact` places
1110
+ a contact-page link, a `/privacy` link and social links. On `/contact`, `StorefrontContact` places
1100
1111
  company details (address, `tel:` phone link, registration numbers with copy
1101
1112
  buttons and social links) in a column beside the bordered message-form panel;
1102
- the columns stack on narrow screens and empty details are hidden.
1113
+ the columns stack on narrow screens and empty details are hidden. `/privacy`
1114
+ shows the store's privacy policy (`StorefrontPrivacy`, texts in
1115
+ `app/utils/orionplace/privacyLabels.ts`) with the operator's details; on the
1116
+ website the layout's `CookieNotice` links its `#cookies` section until the
1117
+ visitor dismisses it.
1103
1118
  The home banner spans the available viewport
1104
1119
  width without a frame. Its media area keeps the same proportions at every width; the image fills the
1105
1120
  area without distortion and crops its edges when needed. Event media keeps its
@@ -3,6 +3,7 @@ import { setupProject } from '../src/setup.mjs';
3
3
  import { updateProject } from '../src/update.mjs';
4
4
  import { installService } from '../src/install.mjs';
5
5
  import { SERVICE_ACTIONS, SERVICE_ALIASES, controlService, findApp, printService } from '../src/service.mjs';
6
+ import { sitemapCommand } from '../src/sitemap.mjs';
6
7
  import { readFile } from 'node:fs/promises';
7
8
  import { resolve } from 'node:path';
8
9
  import { createInterface } from 'node:readline/promises';
@@ -30,6 +31,9 @@ Commands for an existing project (run from its root):
30
31
  npx orionplace-nuxt remove frontend <shop.example.com>
31
32
  npx orionplace-nuxt set mimic <existing-frontend.example.com>
32
33
  npx orionplace-nuxt set frontend <shop.example.com>
34
+ npx orionplace-nuxt sitemap rebuild [domain] rebuild /sitemap.xml now (every frontend, or one);
35
+ the running storefront serves it at once
36
+ npx orionplace-nuxt sitemap status [domain] when each sitemap was built
33
37
 
34
38
  On your Linux server (run from the project root):
35
39
  sudo npx orionplace-nuxt install
@@ -130,6 +134,9 @@ try {
130
134
  }
131
135
  const done = await controlService({ action: command, cwd, name: install.name, lines: positional[1] ?? 100, follow, dryRun });
132
136
  process.exitCode = printService(done);
137
+ } else if (command === 'sitemap' && positional.length <= 3) {
138
+ if (dryRun) throw new Error('--dry-run is not a sitemap option; sitemap status shows the state without changes.');
139
+ process.exitCode = await sitemapCommand({ cwd, action: target, domain: positional[2] });
133
140
  } else if (command === 'init' && positional.length <= 2) {
134
141
  result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
135
142
  } else if (command === 'update' && positional.length === 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-nuxt",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
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,5 +1,5 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
1
+ import { chown, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { dirname, relative, resolve } from 'node:path';
3
3
  import {
4
4
  getDefaultAutoSelectFamilyAttemptTimeout,
5
5
  isIP,
@@ -81,8 +81,20 @@ export async function readMarketplace(request, configuration = {}) {
81
81
  if (!domain) {
82
82
  throw new Error('Invalid storefront domain');
83
83
  }
84
+ return readMarketplaceDomain(domain);
85
+ }
84
86
 
85
- const directory = environment.SITEMAPS_DIR || resolve('sitemaps');
87
+ /**
88
+ * The marketplace of one storefront domain: its `sitemaps/<domain>.cfg` in
89
+ * the project (`root`, the working directory by default).
90
+ * @param {string} name @param {{root?: string}} options
91
+ */
92
+ export async function readMarketplaceDomain(name, options = {}) {
93
+ const domain = normalizeFrontDomain(name);
94
+ if (!domain) {
95
+ throw new Error('Invalid storefront domain');
96
+ }
97
+ const directory = marketplaceConfigDirectory(options.root);
86
98
  const source = await readFile(resolve(directory, `${domain}.cfg`), 'utf8');
87
99
  const lines = source
88
100
  .split(/\r?\n/)
@@ -111,6 +123,13 @@ export async function readMarketplace(request, configuration = {}) {
111
123
  return { domain, api };
112
124
  }
113
125
 
126
+ /** The folder of the domain files (`SITEMAPS_DIR`, or `sitemaps`). */
127
+ export function marketplaceConfigDirectory(root = process.cwd()) {
128
+ return environment.SITEMAPS_DIR
129
+ ? resolve(root, environment.SITEMAPS_DIR)
130
+ : resolve(root, 'sitemaps');
131
+ }
132
+
114
133
  /** @param {number} status @param {string} message */
115
134
  function failure(status, message) {
116
135
  return Response.json(
@@ -297,3 +316,471 @@ export async function forwardMarketplace(request, configuration = {}) {
297
316
  headers: responseHeaders,
298
317
  });
299
318
  }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // robots.txt and sitemap.xml
322
+ //
323
+ // The sitemap lists the storefront's public pages in every language the
324
+ // marketplace offers (`?locale=<code>` links, with hreflang alternates): the
325
+ // home, catalog and directory pages, the journal and its publications when
326
+ // the blog is on, the subscription plans when there are any, every public
327
+ // category and every product on sale. It is built from the backend's public
328
+ // API and kept for an hour per storefront domain in
329
+ // node_modules/.cache/orionplace/sitemaps/<domain>.json (ORIONPLACE_CACHE_DIR
330
+ // moves it). "orionplace-<framework> sitemap rebuild" rebuilds that file at
331
+ // once; the running storefront serves the new file without a restart.
332
+
333
+ export const SITEMAP_LIFETIME = 60 * 60 * 1000;
334
+ const sitemapPageSize = 100;
335
+ const sitemapMaxPages = 500;
336
+ const sitemapMaxUrls = 50_000;
337
+ const sitemapVersion = 1;
338
+ /** Built sitemaps of this process, for when the cache folder is read-only. */
339
+ const sitemapMemory = new Map();
340
+ /** @type {Map<string, Promise<object>>} */
341
+ const sitemapBuilds = new Map();
342
+
343
+ /** The public origin a crawler used: the storefront host, or a local one. */
344
+ export function marketplacePublicOrigin(request, tenantDomain = '') {
345
+ const url = new URL(request.url);
346
+ const host = normalizeFrontDomain(
347
+ request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? '',
348
+ );
349
+ if (host) {
350
+ return `https://${host}`;
351
+ }
352
+ const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(url.hostname);
353
+ return local || !tenantDomain ? url.origin : `https://${tenantDomain}`;
354
+ }
355
+
356
+ /** @param {string} origin */
357
+ export function marketplaceRobots(origin) {
358
+ return `User-agent: *\nDisallow: /api/\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`;
359
+ }
360
+
361
+ /** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
362
+ export async function marketplaceRobotsResponse(request, configuration = {}) {
363
+ let domain = '';
364
+ try {
365
+ domain = (await readMarketplace(request, configuration)).domain;
366
+ } catch {
367
+ domain = '';
368
+ }
369
+ return new Response(
370
+ marketplaceRobots(marketplacePublicOrigin(request, domain)),
371
+ {
372
+ headers: {
373
+ 'Content-Type': 'text/plain; charset=utf-8',
374
+ 'Cache-Control': 'public, max-age=3600',
375
+ 'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
376
+ },
377
+ },
378
+ );
379
+ }
380
+
381
+ /** @param {string} value */
382
+ function escapeXml(value) {
383
+ return value.replace(/[<>&"']/g, (character) => ({
384
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;',
385
+ })[character]);
386
+ }
387
+
388
+ /** @param {unknown} value */
389
+ function publicId(value) {
390
+ const id = Number(value);
391
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
392
+ }
393
+
394
+ /** The marketplace's languages from /appload `init.locale`. */
395
+ export function sitemapLocales(locales) {
396
+ const codes = Object.entries(locales && typeof locales === 'object' ? locales : {})
397
+ .map(([key, item]) => String(item?.code || key).trim().toLowerCase())
398
+ .filter((code) => /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/.test(code));
399
+ return [...new Set(codes)].sort();
400
+ }
401
+
402
+ /** A product edit time (ISO 8601) or a blog post day ({date: 'DD.MM.YYYY'}). */
403
+ export function sitemapLastmod(value, now = Date.now()) {
404
+ if (typeof value === 'string' && value) {
405
+ const time = Date.parse(value);
406
+ return Number.isFinite(time) && time <= now + 60_000
407
+ ? new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z')
408
+ : null;
409
+ }
410
+ const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(value?.date ?? '');
411
+ if (!match) {
412
+ return null;
413
+ }
414
+ const [, day, month, year] = match;
415
+ const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
416
+ const date = new Date(time);
417
+ return date.getUTCDate() === Number(day) &&
418
+ date.getUTCMonth() === Number(month) - 1 &&
419
+ time <= now + 24 * 60 * 60 * 1000
420
+ ? `${year}-${month}-${day}`
421
+ : null;
422
+ }
423
+
424
+ /** The API path (under /api) of a route named in /appload `unauth.paths`. */
425
+ export function sitemapApiPath(paths, name) {
426
+ for (const group of Object.values(paths ?? {})) {
427
+ for (const route of Object.values(group ?? {})) {
428
+ if (route?.url_name === name) {
429
+ const target = String(route.target ?? '').split('/{')[0].replace(/^\/+/, '');
430
+ return target.startsWith('api/') ? `/${target}` : null;
431
+ }
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+
437
+ /** Public categories, parents before children. */
438
+ export function sitemapCategoryIds(categories) {
439
+ const ids = [];
440
+ const visit = (category) => {
441
+ const id = publicId(category?.id);
442
+ if (id && category?.is_public) {
443
+ ids.push(id);
444
+ }
445
+ (category?.childs ?? []).forEach(visit);
446
+ };
447
+ (Array.isArray(categories) ? categories : []).forEach(visit);
448
+ return [...new Set(ids)];
449
+ }
450
+
451
+ /**
452
+ * One <url> per page and language, each with alternates for its languages.
453
+ * A page with its own `locales` (a publication written in some languages
454
+ * only) is listed in those of the marketplace's languages.
455
+ * @param {string} origin
456
+ * @param {{path: string, lastmod?: string | null, locales?: string[]}[]} pages
457
+ * @param {string[]} locales
458
+ */
459
+ export function marketplaceSitemapXml(origin, pages, locales) {
460
+ const href = (path, code) => {
461
+ const url = new URL(path, `${origin}/`);
462
+ if (code) {
463
+ url.searchParams.set('locale', code);
464
+ }
465
+ return escapeXml(url.toString());
466
+ };
467
+ const urls = [];
468
+ for (const page of pages) {
469
+ const own = page.locales
470
+ ? locales.filter((code) => page.locales.includes(code))
471
+ : locales;
472
+ if (page.locales && !own.length) {
473
+ continue;
474
+ }
475
+ const codes = own.length ? own : [''];
476
+ const alternates = own.length > 1
477
+ ? own.map((code) =>
478
+ ` <xhtml:link rel="alternate" hreflang="${escapeXml(code)}" href="${href(page.path, code)}" />\n`).join('')
479
+ : '';
480
+ const lastmod = page.lastmod ? ` <lastmod>${escapeXml(page.lastmod)}</lastmod>\n` : '';
481
+ for (const code of codes) {
482
+ if (urls.length >= sitemapMaxUrls) {
483
+ break;
484
+ }
485
+ urls.push(` <url>\n <loc>${href(page.path, code)}</loc>\n${lastmod}${alternates} </url>\n`);
486
+ }
487
+ }
488
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' +
489
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n' +
490
+ urls.join('') +
491
+ '</urlset>\n';
492
+ }
493
+
494
+ /** @param {{domain: string, api: URL}} tenant @param {string} path @param {unknown} body */
495
+ async function sitemapRequest(tenant, path, body) {
496
+ const response = await fetch(new URL(path, tenant.api.origin), {
497
+ method: 'POST',
498
+ headers: {
499
+ 'Content-Type': 'application/json',
500
+ 'Accept': 'application/json',
501
+ 'X-Front-Domain': tenant.domain,
502
+ },
503
+ body: JSON.stringify(body),
504
+ cache: 'no-store',
505
+ signal: AbortSignal.timeout(requestTimeout),
506
+ });
507
+ if (!response.ok) {
508
+ throw new Error(`${path} answered ${response.status}`);
509
+ }
510
+ return response.json();
511
+ }
512
+
513
+ /** Every record of a paginated list (`page_data[key].data` + `rpag`). */
514
+ async function sitemapList(tenant, path, key, filters = {}) {
515
+ const records = new Map();
516
+ let page = 1;
517
+ for (let count = 0; count < sitemapMaxPages; count++) {
518
+ const payload = await sitemapRequest(tenant, path, {
519
+ filters,
520
+ paginate: { page, pp_items: sitemapPageSize },
521
+ });
522
+ const list = payload?.page_data?.[key];
523
+ if (!Array.isArray(list?.data)) {
524
+ throw new Error(`${path} returned no ${key} list`);
525
+ }
526
+ for (const record of list.data) {
527
+ const id = publicId(record?.id);
528
+ if (id) {
529
+ records.set(id, record);
530
+ }
531
+ }
532
+ const next = Number(list.rpag?.next_page_number);
533
+ if (!list.rpag?.has_next || !Number.isSafeInteger(next) || next <= page) {
534
+ return [...records.values()];
535
+ }
536
+ page = next;
537
+ }
538
+ throw new Error(`${path} has more than ${sitemapMaxPages} pages`);
539
+ }
540
+
541
+ /** Products on sale and in stock states a shopper can open. */
542
+ function productOnSale(product) {
543
+ const status = Number(product?.status?.status ?? product?.status);
544
+ return [2, -2, -3].includes(status);
545
+ }
546
+
547
+ /** Blog publications that are published. */
548
+ function postPublished(post) {
549
+ const state = post?.publishing_state?.int ?? post?.publishing_state;
550
+ return state === undefined || state === null || Number(state) === 1;
551
+ }
552
+
553
+ /** The languages a publication is written in (it has a title there). */
554
+ function postLocales(post) {
555
+ return Object.entries(post?.locale && typeof post.locale === 'object' ? post.locale : {})
556
+ .filter(([, text]) => typeof text?.post_title === 'string' && text.post_title.trim())
557
+ .map(([code]) => code.toLowerCase());
558
+ }
559
+
560
+ /** @param {{domain: string, api: URL}} tenant */
561
+ async function buildMarketplaceSitemap(tenant) {
562
+ const appload = await sitemapRequest(tenant, '/api/appload', {
563
+ detalize_mode: 'light',
564
+ ignore_cache: false,
565
+ });
566
+ const init = appload?.page_data?.init;
567
+ const paths = appload?.page_data?.unauth?.paths;
568
+ if (!init || !Array.isArray(init.catalog)) {
569
+ throw new Error('appload returned no catalog');
570
+ }
571
+ const locales = sitemapLocales(init.locale);
572
+ const categories = sitemapCategoryIds(init.catalog);
573
+ const blogPath = sitemapApiPath(paths, 'blog_posts_get');
574
+ const searchPath = sitemapApiPath(paths, 'products_search');
575
+ const [products, posts] = await Promise.all([
576
+ searchPath && categories.length
577
+ ? sitemapList(tenant, searchPath, 'products', { categories })
578
+ : [],
579
+ blogPath ? sitemapList(tenant, blogPath, 'posts') : [],
580
+ ]);
581
+ const postPages = posts
582
+ .filter(postPublished)
583
+ .sort((left, right) => left.id - right.id)
584
+ .map((post) => ({
585
+ path: `/journal/${post.id}`,
586
+ lastmod: sitemapLastmod(post.date_updated),
587
+ locales: postLocales(post),
588
+ }));
589
+ const newestPost = postPages.map((page) => page.lastmod).filter(Boolean).sort().at(-1) ?? null;
590
+ const pages = [
591
+ { path: '/' },
592
+ { path: '/catalog' },
593
+ ...(blogPath ? [{ path: '/journal', lastmod: newestPost }] : []),
594
+ ...(init.tariffs?.data?.length ? [{ path: '/tariffs' }] : []),
595
+ { path: '/manufacturers' },
596
+ ...(init.visual_settings?.single_seller_mode ? [] : [{ path: '/sellers' }]),
597
+ { path: '/stores' },
598
+ { path: '/contact' },
599
+ { path: '/privacy' },
600
+ ...categories.map((id) => ({ path: `/category/${id}` })),
601
+ ...products
602
+ .filter(productOnSale)
603
+ .map((product) => ({
604
+ path: `/product/${product.id}`,
605
+ lastmod: sitemapLastmod(product.date_updated),
606
+ })),
607
+ ...postPages,
608
+ ];
609
+ return {
610
+ version: sitemapVersion,
611
+ domain: tenant.domain,
612
+ built: new Date().toISOString(),
613
+ locales,
614
+ pages,
615
+ };
616
+ }
617
+
618
+ /** How many <url> entries a built sitemap has. */
619
+ export function sitemapUrlCount(sitemap) {
620
+ let count = 0;
621
+ for (const page of sitemap.pages) {
622
+ const own = page.locales
623
+ ? sitemap.locales.filter((code) => page.locales.includes(code))
624
+ : sitemap.locales;
625
+ count += page.locales ? own.length : Math.max(own.length, 1);
626
+ }
627
+ return Math.min(count, sitemapMaxUrls);
628
+ }
629
+
630
+ /** The cache folder of built sitemaps. */
631
+ export function sitemapCacheDirectory(root = process.cwd()) {
632
+ return environment.ORIONPLACE_CACHE_DIR
633
+ ? resolve(root, environment.ORIONPLACE_CACHE_DIR, 'sitemaps')
634
+ : resolve(root, 'node_modules', '.cache', 'orionplace', 'sitemaps');
635
+ }
636
+
637
+ /** @param {string} domain @param {string} root */
638
+ function sitemapCachePath(domain, root) {
639
+ return resolve(sitemapCacheDirectory(root), `${domain}.json`);
640
+ }
641
+
642
+ /** A cached sitemap of the domain, or null. @param {string} domain */
643
+ export async function readSitemapCache(domain, root = process.cwd()) {
644
+ let sitemap;
645
+ try {
646
+ sitemap = JSON.parse(await readFile(sitemapCachePath(domain, root), 'utf8'));
647
+ } catch {
648
+ return null;
649
+ }
650
+ return sitemap?.version === sitemapVersion &&
651
+ sitemap.domain === domain &&
652
+ Number.isFinite(Date.parse(sitemap.built)) &&
653
+ Array.isArray(sitemap.locales) &&
654
+ Array.isArray(sitemap.pages)
655
+ ? sitemap
656
+ : null;
657
+ }
658
+
659
+ /**
660
+ * Write the cache file. When root writes it (sudo), the folders it creates
661
+ * and the file go to the project owner, so the storefront service can
662
+ * replace them later.
663
+ */
664
+ async function writeSitemapCache(sitemap, root) {
665
+ const path = sitemapCachePath(sitemap.domain, root);
666
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
667
+ await mkdir(dirname(path), { recursive: true });
668
+ try {
669
+ await writeFile(temporary, JSON.stringify(sitemap), { mode: 0o644 });
670
+ await rename(temporary, path);
671
+ } catch (error) {
672
+ await unlink(temporary).catch(() => {});
673
+ throw error;
674
+ }
675
+ if (process.getuid?.() === 0) {
676
+ const owner = await stat(root);
677
+ if (owner.uid !== 0) {
678
+ let cursor = path;
679
+ while (cursor !== root && !relative(root, cursor).startsWith('..')) {
680
+ const info = await stat(cursor);
681
+ if (info.uid === 0) {
682
+ await chown(cursor, owner.uid, owner.gid);
683
+ }
684
+ cursor = dirname(cursor);
685
+ }
686
+ }
687
+ }
688
+ return path;
689
+ }
690
+
691
+ /** @param {{domain: string, api: URL}} tenant @param {string} root */
692
+ function buildOnce(tenant, root) {
693
+ let build = sitemapBuilds.get(tenant.domain);
694
+ if (!build) {
695
+ build = buildMarketplaceSitemap(tenant)
696
+ .then(async (sitemap) => {
697
+ sitemapMemory.set(tenant.domain, sitemap);
698
+ try {
699
+ await writeSitemapCache(sitemap, root);
700
+ } catch (error) {
701
+ console.error(`[orionplace] Sitemap cache not written: ${error?.code ?? error?.message ?? error}`);
702
+ }
703
+ return sitemap;
704
+ })
705
+ .finally(() => sitemapBuilds.delete(tenant.domain));
706
+ sitemapBuilds.set(tenant.domain, build);
707
+ }
708
+ return build;
709
+ }
710
+
711
+ /**
712
+ * Rebuild the sitemap of one storefront domain now and store it where the
713
+ * running storefront reads it (the "sitemap rebuild" command).
714
+ * @param {string} domain @param {{root?: string}} options
715
+ */
716
+ export async function rebuildMarketplaceSitemap(domain, options = {}) {
717
+ const root = resolve(options.root ?? process.cwd());
718
+ const tenant = await readMarketplaceDomain(domain, { root });
719
+ const sitemap = await buildMarketplaceSitemap(tenant);
720
+ const path = await writeSitemapCache(sitemap, root);
721
+ return {
722
+ domain: tenant.domain,
723
+ path,
724
+ built: sitemap.built,
725
+ urls: sitemapUrlCount(sitemap),
726
+ pages: sitemap.pages.length,
727
+ publications: sitemap.pages.filter((page) => page.path.startsWith('/journal/')).length,
728
+ };
729
+ }
730
+
731
+ /** The storefront domains of the project (its `sitemaps/*.cfg` files). */
732
+ export async function marketplaceDomains(root = process.cwd()) {
733
+ let names;
734
+ try {
735
+ names = await readdir(marketplaceConfigDirectory(root));
736
+ } catch (error) {
737
+ if (error?.code === 'ENOENT') {
738
+ return [];
739
+ }
740
+ throw error;
741
+ }
742
+ return names
743
+ .filter((name) => name.endsWith('.cfg'))
744
+ .map((name) => normalizeFrontDomain(name.slice(0, -4)))
745
+ .filter(Boolean)
746
+ .sort();
747
+ }
748
+
749
+ /** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
750
+ export async function marketplaceSitemapResponse(request, configuration = {}) {
751
+ let tenant;
752
+ try {
753
+ tenant = await readMarketplace(request, configuration);
754
+ } catch {
755
+ return failure(503, 'Storefront domain configuration is missing or invalid');
756
+ }
757
+ const root = process.cwd();
758
+ const fresh = (sitemap) =>
759
+ sitemap && Date.now() - Date.parse(sitemap.built) < SITEMAP_LIFETIME;
760
+ // The file wins when it is newer: a "sitemap rebuild" replaced it.
761
+ const file = await readSitemapCache(tenant.domain, root);
762
+ const memory = sitemapMemory.get(tenant.domain);
763
+ const newest = [file, memory]
764
+ .filter(Boolean)
765
+ .sort((left, right) => Date.parse(right.built) - Date.parse(left.built))[0];
766
+ let sitemap = fresh(newest) ? newest : null;
767
+ if (!sitemap) {
768
+ try {
769
+ sitemap = await buildOnce(tenant, root);
770
+ } catch (error) {
771
+ console.error(`[orionplace] Sitemap build failed: ${error?.message ?? error}`);
772
+ if (!newest) {
773
+ return failure(502, 'Marketplace backend is unavailable');
774
+ }
775
+ sitemap = newest;
776
+ }
777
+ }
778
+ const origin = marketplacePublicOrigin(request, tenant.domain);
779
+ return new Response(marketplaceSitemapXml(origin, sitemap.pages, sitemap.locales), {
780
+ headers: {
781
+ 'Content-Type': 'application/xml; charset=utf-8',
782
+ 'Cache-Control': 'public, max-age=3600',
783
+ 'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
784
+ },
785
+ });
786
+ }
@@ -0,0 +1,17 @@
1
+ // robots.txt and sitemap.xml of the storefront domain (see marketplace.mjs).
2
+ import { defineEventHandler, getRequestURL, toWebRequest, sendWebResponse } from 'h3';
3
+ import { useRuntimeConfig } from '#imports';
4
+ import { marketplaceRobotsResponse, marketplaceSitemapResponse } from './marketplace.mjs';
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const configuration = {
8
+ mimicFrontDomain:
9
+ process.env.MIMIC_FRONT_DOMAIN ??
10
+ useRuntimeConfig(event).mimicFrontDomain,
11
+ };
12
+ const request = toWebRequest(event);
13
+ const response = getRequestURL(event).pathname === '/robots.txt'
14
+ ? await marketplaceRobotsResponse(request, configuration)
15
+ : await marketplaceSitemapResponse(request, configuration);
16
+ return sendWebResponse(event, response);
17
+ });
package/src/module.mjs CHANGED
@@ -75,6 +75,12 @@ export default defineNuxtModule({
75
75
  addServerHandler({ route: '/api/**', handler: resolve(directory, 'api.mjs') });
76
76
  // Guarded blog images and videos; the runtime accepts only those paths.
77
77
  addServerHandler({ route: '/ps3/blog/**', handler: resolve(directory, 'api.mjs') });
78
+ // robots.txt and the sitemap of the storefront domain (projects set up
79
+ // earlier get seo.mjs with `npx orionplace-nuxt update`).
80
+ if (existsSync(resolve(directory, 'seo.mjs'))) {
81
+ addServerHandler({ route: '/robots.txt', method: 'get', handler: resolve(directory, 'seo.mjs') });
82
+ addServerHandler({ route: '/sitemap.xml', method: 'get', handler: resolve(directory, 'seo.mjs') });
83
+ }
78
84
  addServerHandler({ middleware: true, handler: resolve(directory, 'domain.mjs') });
79
85
  addServerPlugin(resolve(import.meta.dirname, 'asset-loading-plugin.mjs'));
80
86
  },