orionplace-react 0.5.0 → 0.6.1

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 (30) hide show
  1. package/README.md +10 -1
  2. package/bin/orionplace-react.mjs +7 -0
  3. package/package.json +1 -1
  4. package/runtime/marketplace.d.mts +97 -0
  5. package/runtime/marketplace.mjs +519 -3
  6. package/src/project-readme.mjs +23 -2
  7. package/src/setup.mjs +1 -0
  8. package/src/sitemap.mjs +93 -0
  9. package/templates/app/_storefront/CategoryPage.tsx +6 -2
  10. package/templates/app/_storefront/SearchPage.tsx +7 -2
  11. package/templates/app/_storefront/SellersPage.tsx +9 -3
  12. package/templates/app/_storefront/storefrontRoutes.tsx +31 -10
  13. package/templates/app/_storefront/useProductSearch.ts +97 -26
  14. package/templates/app/robots.txt/route.ts +11 -0
  15. package/templates/app/sitemap.xml/route.ts +12 -0
  16. package/templates/components/orionplace/account/ProfileDetailsForm.README.md +4 -0
  17. package/templates/components/orionplace/account/ProfileDetailsForm.tsx +7 -2
  18. package/templates/components/orionplace/cart/CartCheckoutDetails.README.md +4 -0
  19. package/templates/components/orionplace/cart/CartCheckoutDetails.tsx +20 -9
  20. package/templates/interfaces/orionplace/accounts/IProfileData.ts +2 -0
  21. package/templates/interfaces/orionplace/appBase/IApploadUserProfile.ts +2 -0
  22. package/templates/lib/orionplace/cartLabels.ts +2 -0
  23. package/templates/lib/orionplace/pageMetadata.ts +24 -4
  24. package/templates/lib/orionplace/pageSeo.ts +68 -0
  25. package/templates/lib/orionplace/productListLocation.ts +130 -0
  26. package/templates/lib/orionplace/productSearch.ts +48 -0
  27. package/templates/lib/orionplace/profile.ts +12 -0
  28. package/templates/lib/orionplace/profileLabels.ts +2 -0
  29. package/templates/lib/orionplace/serverMetadata.ts +7 -0
  30. package/templates/proxy.ts +8 -2
@@ -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,500 @@ 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
+ // Paths in ORIONPLACE_NOINDEX_PATHS are left out when the sitemap is served.
333
+
334
+ export const SITEMAP_LIFETIME = 60 * 60 * 1000;
335
+ const sitemapPageSize = 100;
336
+ const sitemapMaxPages = 500;
337
+ const sitemapMaxUrls = 50_000;
338
+ const sitemapVersion = 1;
339
+ /** Built sitemaps of this process, for when the cache folder is read-only. */
340
+ const sitemapMemory = new Map();
341
+ /** @type {Map<string, Promise<object>>} */
342
+ const sitemapBuilds = new Map();
343
+
344
+ /** The public origin a crawler used: the storefront host, or a local one. */
345
+ export function marketplacePublicOrigin(request, tenantDomain = '') {
346
+ const url = new URL(request.url);
347
+ const host = normalizeFrontDomain(
348
+ request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? '',
349
+ );
350
+ if (host) {
351
+ return `https://${host}`;
352
+ }
353
+ const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(url.hostname);
354
+ return local || !tenantDomain ? url.origin : `https://${tenantDomain}`;
355
+ }
356
+
357
+ /**
358
+ * Pages kept out of search engines: ORIONPLACE_NOINDEX_PATHS lists path
359
+ * prefixes separated by spaces or commas, for example "/catalog /category
360
+ * /product" on a demo or staging storefront. Those pages answer with
361
+ * `X-Robots-Tag: noindex, follow` and the sitemap leaves them out. Empty
362
+ * (the default) keeps every public page indexable.
363
+ */
364
+ export function noindexPathPrefixes(value = environment.ORIONPLACE_NOINDEX_PATHS) {
365
+ return String(value ?? '')
366
+ .split(/[\s,]+/)
367
+ .map((item) => item.trim().replace(/\/+$/, ''))
368
+ .filter((item) => /^\/[A-Za-z0-9._~\/-]+$/.test(item));
369
+ }
370
+
371
+ /** Whether a page path is kept out of search engines (see noindexPathPrefixes). */
372
+ export function marketplaceNoindexPath(pathname, value = environment.ORIONPLACE_NOINDEX_PATHS) {
373
+ const path = String(pathname ?? '');
374
+ return noindexPathPrefixes(value)
375
+ .some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
376
+ }
377
+
378
+ /** The sitemap pages search engines may index. */
379
+ export function sitemapIndexedPages(pages, value = environment.ORIONPLACE_NOINDEX_PATHS) {
380
+ return noindexPathPrefixes(value).length
381
+ ? pages.filter((page) => !marketplaceNoindexPath(page.path, value))
382
+ : pages;
383
+ }
384
+
385
+ /** @param {string} origin */
386
+ export function marketplaceRobots(origin) {
387
+ return `User-agent: *\nDisallow: /api/\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`;
388
+ }
389
+
390
+ /** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
391
+ export async function marketplaceRobotsResponse(request, configuration = {}) {
392
+ let domain = '';
393
+ try {
394
+ domain = (await readMarketplace(request, configuration)).domain;
395
+ } catch {
396
+ domain = '';
397
+ }
398
+ return new Response(
399
+ marketplaceRobots(marketplacePublicOrigin(request, domain)),
400
+ {
401
+ headers: {
402
+ 'Content-Type': 'text/plain; charset=utf-8',
403
+ 'Cache-Control': 'public, max-age=3600',
404
+ 'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
405
+ },
406
+ },
407
+ );
408
+ }
409
+
410
+ /** @param {string} value */
411
+ function escapeXml(value) {
412
+ return value.replace(/[<>&"']/g, (character) => ({
413
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;',
414
+ })[character]);
415
+ }
416
+
417
+ /** @param {unknown} value */
418
+ function publicId(value) {
419
+ const id = Number(value);
420
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
421
+ }
422
+
423
+ /** The marketplace's languages from /appload `init.locale`. */
424
+ export function sitemapLocales(locales) {
425
+ const codes = Object.entries(locales && typeof locales === 'object' ? locales : {})
426
+ .map(([key, item]) => String(item?.code || key).trim().toLowerCase())
427
+ .filter((code) => /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/.test(code));
428
+ return [...new Set(codes)].sort();
429
+ }
430
+
431
+ /** A product edit time (ISO 8601) or a blog post day ({date: 'DD.MM.YYYY'}). */
432
+ export function sitemapLastmod(value, now = Date.now()) {
433
+ if (typeof value === 'string' && value) {
434
+ const time = Date.parse(value);
435
+ return Number.isFinite(time) && time <= now + 60_000
436
+ ? new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z')
437
+ : null;
438
+ }
439
+ const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(value?.date ?? '');
440
+ if (!match) {
441
+ return null;
442
+ }
443
+ const [, day, month, year] = match;
444
+ const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
445
+ const date = new Date(time);
446
+ return date.getUTCDate() === Number(day) &&
447
+ date.getUTCMonth() === Number(month) - 1 &&
448
+ time <= now + 24 * 60 * 60 * 1000
449
+ ? `${year}-${month}-${day}`
450
+ : null;
451
+ }
452
+
453
+ /** The API path (under /api) of a route named in /appload `unauth.paths`. */
454
+ export function sitemapApiPath(paths, name) {
455
+ for (const group of Object.values(paths ?? {})) {
456
+ for (const route of Object.values(group ?? {})) {
457
+ if (route?.url_name === name) {
458
+ const target = String(route.target ?? '').split('/{')[0].replace(/^\/+/, '');
459
+ return target.startsWith('api/') ? `/${target}` : null;
460
+ }
461
+ }
462
+ }
463
+ return null;
464
+ }
465
+
466
+ /** Public categories, parents before children. */
467
+ export function sitemapCategoryIds(categories) {
468
+ const ids = [];
469
+ const visit = (category) => {
470
+ const id = publicId(category?.id);
471
+ if (id && category?.is_public) {
472
+ ids.push(id);
473
+ }
474
+ (category?.childs ?? []).forEach(visit);
475
+ };
476
+ (Array.isArray(categories) ? categories : []).forEach(visit);
477
+ return [...new Set(ids)];
478
+ }
479
+
480
+ /**
481
+ * One <url> per page and language, each with alternates for its languages.
482
+ * A page with its own `locales` (a publication written in some languages
483
+ * only) is listed in those of the marketplace's languages.
484
+ * @param {string} origin
485
+ * @param {{path: string, lastmod?: string | null, locales?: string[]}[]} pages
486
+ * @param {string[]} locales
487
+ */
488
+ export function marketplaceSitemapXml(origin, pages, locales) {
489
+ const href = (path, code) => {
490
+ const url = new URL(path, `${origin}/`);
491
+ if (code) {
492
+ url.searchParams.set('locale', code);
493
+ }
494
+ return escapeXml(url.toString());
495
+ };
496
+ const urls = [];
497
+ for (const page of pages) {
498
+ const own = page.locales
499
+ ? locales.filter((code) => page.locales.includes(code))
500
+ : locales;
501
+ if (page.locales && !own.length) {
502
+ continue;
503
+ }
504
+ const codes = own.length ? own : [''];
505
+ const alternates = own.length > 1
506
+ ? own.map((code) =>
507
+ ` <xhtml:link rel="alternate" hreflang="${escapeXml(code)}" href="${href(page.path, code)}" />\n`).join('')
508
+ : '';
509
+ const lastmod = page.lastmod ? ` <lastmod>${escapeXml(page.lastmod)}</lastmod>\n` : '';
510
+ for (const code of codes) {
511
+ if (urls.length >= sitemapMaxUrls) {
512
+ break;
513
+ }
514
+ urls.push(` <url>\n <loc>${href(page.path, code)}</loc>\n${lastmod}${alternates} </url>\n`);
515
+ }
516
+ }
517
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' +
518
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n' +
519
+ urls.join('') +
520
+ '</urlset>\n';
521
+ }
522
+
523
+ /** @param {{domain: string, api: URL}} tenant @param {string} path @param {unknown} body */
524
+ async function sitemapRequest(tenant, path, body) {
525
+ const response = await fetch(new URL(path, tenant.api.origin), {
526
+ method: 'POST',
527
+ headers: {
528
+ 'Content-Type': 'application/json',
529
+ 'Accept': 'application/json',
530
+ 'X-Front-Domain': tenant.domain,
531
+ },
532
+ body: JSON.stringify(body),
533
+ cache: 'no-store',
534
+ signal: AbortSignal.timeout(requestTimeout),
535
+ });
536
+ if (!response.ok) {
537
+ throw new Error(`${path} answered ${response.status}`);
538
+ }
539
+ return response.json();
540
+ }
541
+
542
+ /** Every record of a paginated list (`page_data[key].data` + `rpag`). */
543
+ async function sitemapList(tenant, path, key, filters = {}) {
544
+ const records = new Map();
545
+ let page = 1;
546
+ for (let count = 0; count < sitemapMaxPages; count++) {
547
+ const payload = await sitemapRequest(tenant, path, {
548
+ filters,
549
+ paginate: { page, pp_items: sitemapPageSize },
550
+ });
551
+ const list = payload?.page_data?.[key];
552
+ if (!Array.isArray(list?.data)) {
553
+ throw new Error(`${path} returned no ${key} list`);
554
+ }
555
+ for (const record of list.data) {
556
+ const id = publicId(record?.id);
557
+ if (id) {
558
+ records.set(id, record);
559
+ }
560
+ }
561
+ const next = Number(list.rpag?.next_page_number);
562
+ if (!list.rpag?.has_next || !Number.isSafeInteger(next) || next <= page) {
563
+ return [...records.values()];
564
+ }
565
+ page = next;
566
+ }
567
+ throw new Error(`${path} has more than ${sitemapMaxPages} pages`);
568
+ }
569
+
570
+ /** Products on sale and in stock states a shopper can open. */
571
+ function productOnSale(product) {
572
+ const status = Number(product?.status?.status ?? product?.status);
573
+ return [2, -2, -3].includes(status);
574
+ }
575
+
576
+ /** Blog publications that are published. */
577
+ function postPublished(post) {
578
+ const state = post?.publishing_state?.int ?? post?.publishing_state;
579
+ return state === undefined || state === null || Number(state) === 1;
580
+ }
581
+
582
+ /** The languages a publication is written in (it has a title there). */
583
+ function postLocales(post) {
584
+ return Object.entries(post?.locale && typeof post.locale === 'object' ? post.locale : {})
585
+ .filter(([, text]) => typeof text?.post_title === 'string' && text.post_title.trim())
586
+ .map(([code]) => code.toLowerCase());
587
+ }
588
+
589
+ /** @param {{domain: string, api: URL}} tenant */
590
+ async function buildMarketplaceSitemap(tenant) {
591
+ const appload = await sitemapRequest(tenant, '/api/appload', {
592
+ detalize_mode: 'light',
593
+ ignore_cache: false,
594
+ });
595
+ const init = appload?.page_data?.init;
596
+ const paths = appload?.page_data?.unauth?.paths;
597
+ if (!init || !Array.isArray(init.catalog)) {
598
+ throw new Error('appload returned no catalog');
599
+ }
600
+ const locales = sitemapLocales(init.locale);
601
+ const categories = sitemapCategoryIds(init.catalog);
602
+ const blogPath = sitemapApiPath(paths, 'blog_posts_get');
603
+ const searchPath = sitemapApiPath(paths, 'products_search');
604
+ const [products, posts] = await Promise.all([
605
+ searchPath && categories.length
606
+ ? sitemapList(tenant, searchPath, 'products', { categories })
607
+ : [],
608
+ blogPath ? sitemapList(tenant, blogPath, 'posts') : [],
609
+ ]);
610
+ const postPages = posts
611
+ .filter(postPublished)
612
+ .sort((left, right) => left.id - right.id)
613
+ .map((post) => ({
614
+ path: `/journal/${post.id}`,
615
+ lastmod: sitemapLastmod(post.date_updated),
616
+ locales: postLocales(post),
617
+ }));
618
+ const newestPost = postPages.map((page) => page.lastmod).filter(Boolean).sort().at(-1) ?? null;
619
+ const pages = [
620
+ { path: '/' },
621
+ { path: '/catalog' },
622
+ ...(blogPath ? [{ path: '/journal', lastmod: newestPost }] : []),
623
+ ...(init.tariffs?.data?.length ? [{ path: '/tariffs' }] : []),
624
+ { path: '/manufacturers' },
625
+ ...(init.visual_settings?.single_seller_mode ? [] : [{ path: '/sellers' }]),
626
+ { path: '/stores' },
627
+ { path: '/contact' },
628
+ { path: '/privacy' },
629
+ ...categories.map((id) => ({ path: `/category/${id}` })),
630
+ ...products
631
+ .filter(productOnSale)
632
+ .map((product) => ({
633
+ path: `/product/${product.id}`,
634
+ lastmod: sitemapLastmod(product.date_updated),
635
+ })),
636
+ ...postPages,
637
+ ];
638
+ return {
639
+ version: sitemapVersion,
640
+ domain: tenant.domain,
641
+ built: new Date().toISOString(),
642
+ locales,
643
+ pages,
644
+ };
645
+ }
646
+
647
+ /** How many <url> entries a built sitemap has. */
648
+ export function sitemapUrlCount(sitemap) {
649
+ let count = 0;
650
+ for (const page of sitemapIndexedPages(sitemap.pages)) {
651
+ const own = page.locales
652
+ ? sitemap.locales.filter((code) => page.locales.includes(code))
653
+ : sitemap.locales;
654
+ count += page.locales ? own.length : Math.max(own.length, 1);
655
+ }
656
+ return Math.min(count, sitemapMaxUrls);
657
+ }
658
+
659
+ /** The cache folder of built sitemaps. */
660
+ export function sitemapCacheDirectory(root = process.cwd()) {
661
+ return environment.ORIONPLACE_CACHE_DIR
662
+ ? resolve(root, environment.ORIONPLACE_CACHE_DIR, 'sitemaps')
663
+ : resolve(root, 'node_modules', '.cache', 'orionplace', 'sitemaps');
664
+ }
665
+
666
+ /** @param {string} domain @param {string} root */
667
+ function sitemapCachePath(domain, root) {
668
+ return resolve(sitemapCacheDirectory(root), `${domain}.json`);
669
+ }
670
+
671
+ /** A cached sitemap of the domain, or null. @param {string} domain */
672
+ export async function readSitemapCache(domain, root = process.cwd()) {
673
+ let sitemap;
674
+ try {
675
+ sitemap = JSON.parse(await readFile(sitemapCachePath(domain, root), 'utf8'));
676
+ } catch {
677
+ return null;
678
+ }
679
+ return sitemap?.version === sitemapVersion &&
680
+ sitemap.domain === domain &&
681
+ Number.isFinite(Date.parse(sitemap.built)) &&
682
+ Array.isArray(sitemap.locales) &&
683
+ Array.isArray(sitemap.pages)
684
+ ? sitemap
685
+ : null;
686
+ }
687
+
688
+ /**
689
+ * Write the cache file. When root writes it (sudo), the folders it creates
690
+ * and the file go to the project owner, so the storefront service can
691
+ * replace them later.
692
+ */
693
+ async function writeSitemapCache(sitemap, root) {
694
+ const path = sitemapCachePath(sitemap.domain, root);
695
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
696
+ await mkdir(dirname(path), { recursive: true });
697
+ try {
698
+ await writeFile(temporary, JSON.stringify(sitemap), { mode: 0o644 });
699
+ await rename(temporary, path);
700
+ } catch (error) {
701
+ await unlink(temporary).catch(() => {});
702
+ throw error;
703
+ }
704
+ if (process.getuid?.() === 0) {
705
+ const owner = await stat(root);
706
+ if (owner.uid !== 0) {
707
+ let cursor = path;
708
+ while (cursor !== root && !relative(root, cursor).startsWith('..')) {
709
+ const info = await stat(cursor);
710
+ if (info.uid === 0) {
711
+ await chown(cursor, owner.uid, owner.gid);
712
+ }
713
+ cursor = dirname(cursor);
714
+ }
715
+ }
716
+ }
717
+ return path;
718
+ }
719
+
720
+ /** @param {{domain: string, api: URL}} tenant @param {string} root */
721
+ function buildOnce(tenant, root) {
722
+ let build = sitemapBuilds.get(tenant.domain);
723
+ if (!build) {
724
+ build = buildMarketplaceSitemap(tenant)
725
+ .then(async (sitemap) => {
726
+ sitemapMemory.set(tenant.domain, sitemap);
727
+ try {
728
+ await writeSitemapCache(sitemap, root);
729
+ } catch (error) {
730
+ console.error(`[orionplace] Sitemap cache not written: ${error?.code ?? error?.message ?? error}`);
731
+ }
732
+ return sitemap;
733
+ })
734
+ .finally(() => sitemapBuilds.delete(tenant.domain));
735
+ sitemapBuilds.set(tenant.domain, build);
736
+ }
737
+ return build;
738
+ }
739
+
740
+ /**
741
+ * Rebuild the sitemap of one storefront domain now and store it where the
742
+ * running storefront reads it (the "sitemap rebuild" command).
743
+ * @param {string} domain @param {{root?: string}} options
744
+ */
745
+ export async function rebuildMarketplaceSitemap(domain, options = {}) {
746
+ const root = resolve(options.root ?? process.cwd());
747
+ const tenant = await readMarketplaceDomain(domain, { root });
748
+ const sitemap = await buildMarketplaceSitemap(tenant);
749
+ const path = await writeSitemapCache(sitemap, root);
750
+ return {
751
+ domain: tenant.domain,
752
+ path,
753
+ built: sitemap.built,
754
+ urls: sitemapUrlCount(sitemap),
755
+ pages: sitemap.pages.length,
756
+ publications: sitemap.pages.filter((page) => page.path.startsWith('/journal/')).length,
757
+ };
758
+ }
759
+
760
+ /** The storefront domains of the project (its `sitemaps/*.cfg` files). */
761
+ export async function marketplaceDomains(root = process.cwd()) {
762
+ let names;
763
+ try {
764
+ names = await readdir(marketplaceConfigDirectory(root));
765
+ } catch (error) {
766
+ if (error?.code === 'ENOENT') {
767
+ return [];
768
+ }
769
+ throw error;
770
+ }
771
+ return names
772
+ .filter((name) => name.endsWith('.cfg'))
773
+ .map((name) => normalizeFrontDomain(name.slice(0, -4)))
774
+ .filter(Boolean)
775
+ .sort();
776
+ }
777
+
778
+ /** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
779
+ export async function marketplaceSitemapResponse(request, configuration = {}) {
780
+ let tenant;
781
+ try {
782
+ tenant = await readMarketplace(request, configuration);
783
+ } catch {
784
+ return failure(503, 'Storefront domain configuration is missing or invalid');
785
+ }
786
+ const root = process.cwd();
787
+ const fresh = (sitemap) =>
788
+ sitemap && Date.now() - Date.parse(sitemap.built) < SITEMAP_LIFETIME;
789
+ // The file wins when it is newer: a "sitemap rebuild" replaced it.
790
+ const file = await readSitemapCache(tenant.domain, root);
791
+ const memory = sitemapMemory.get(tenant.domain);
792
+ const newest = [file, memory]
793
+ .filter(Boolean)
794
+ .sort((left, right) => Date.parse(right.built) - Date.parse(left.built))[0];
795
+ let sitemap = fresh(newest) ? newest : null;
796
+ if (!sitemap) {
797
+ try {
798
+ sitemap = await buildOnce(tenant, root);
799
+ } catch (error) {
800
+ console.error(`[orionplace] Sitemap build failed: ${error?.message ?? error}`);
801
+ if (!newest) {
802
+ return failure(502, 'Marketplace backend is unavailable');
803
+ }
804
+ sitemap = newest;
805
+ }
806
+ }
807
+ const origin = marketplacePublicOrigin(request, tenant.domain);
808
+ return new Response(marketplaceSitemapXml(origin, sitemapIndexedPages(sitemap.pages), sitemap.locales), {
809
+ headers: {
810
+ 'Content-Type': 'application/xml; charset=utf-8',
811
+ 'Cache-Control': 'public, max-age=3600',
812
+ 'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
813
+ },
814
+ });
815
+ }
@@ -89,6 +89,7 @@ and Next.js configuration are preserved.
89
89
  | \`../${base}app/_storefront/\` | Route controllers that connect components, navigation and API actions. |
90
90
  | \`../${base}app/api/[...orionplace]/route.ts\` | The route handler serving \`/api/...\` from your project. |
91
91
  | \`../${base}app/ps3/blog/[...orionplace]/route.ts\` | The route handler serving guarded blog images and videos under \`/ps3/blog/...\`. |
92
+ | \`../${base}app/robots.txt/route.ts\`, \`../${base}app/sitemap.xml/route.ts\` | \`/robots.txt\` and the storefront sitemap. |
92
93
  | \`../${base}proxy.ts\` | Marks personalized pages private and answers HTTP 503 when Application Load fails. |
93
94
  | \`../sitemaps/${frontend}.cfg\` | The backend URL used for this frontend domain. |
94
95
  | \`../.env\` | Local MIMIC setting; keep secrets out of version control. |
@@ -389,8 +390,28 @@ location / {
389
390
 
390
391
  Here localhost is your frontend process on your VPS. That process selects the
391
392
  remote personal API from the domain file. The proxy must overwrite those headers.
392
- An unknown domain never falls back to another tenant. This setup does not generate
393
- sitemap XML or add analytics.
393
+ An unknown domain never falls back to another tenant. \`/robots.txt\` points
394
+ crawlers to \`/sitemap.xml\`, which lists the public pages, categories, products
395
+ on sale and journal publications in every storefront language (each publication
396
+ only in the languages it is written in). The sitemap is built from the backend
397
+ and kept for an hour in \`node_modules/.cache/orionplace/sitemaps/\`
398
+ (\`ORIONPLACE_CACHE_DIR\` moves it). After you publish or change content, rebuild
399
+ it at once from the project folder; the running storefront serves the new
400
+ sitemap without a restart:
401
+
402
+ \`\`\`bash
403
+ npm run orionplace:sitemap # every frontend domain
404
+ npx orionplace-react sitemap rebuild shop.example.com # one domain
405
+ npx orionplace-react sitemap status # when each was built
406
+ \`\`\`
407
+
408
+ To keep a demo or staging storefront's shop pages out of search engines, set
409
+ \`ORIONPLACE_NOINDEX_PATHS\` in \`.env\` to path prefixes separated by spaces,
410
+ for example \`ORIONPLACE_NOINDEX_PATHS="/catalog /category /product"\`. Those
411
+ pages answer with \`X-Robots-Tag: noindex, follow\` and the sitemap leaves them
412
+ out; the journal and other pages stay indexable. Leave it empty on a live shop.
413
+
414
+ This setup does not add analytics.
394
415
 
395
416
  ## Android app
396
417
 
package/src/setup.mjs CHANGED
@@ -187,6 +187,7 @@ export async function setupProject({ cwd = process.cwd(), action = 'set', field,
187
187
  pkg.scripts['orionplace:remove-frontend'] ||= 'orionplace-react remove frontend';
188
188
  pkg.scripts['orionplace:mimic'] ||= 'orionplace-react set mimic';
189
189
  pkg.scripts['orionplace:help'] ||= 'orionplace-react help';
190
+ pkg.scripts['orionplace:sitemap'] ||= 'orionplace-react sitemap rebuild';
190
191
  pkg.scripts['orionplace:update'] ||= 'orionplace-react update';
191
192
  pkg.dependencies ||= {};
192
193
  pkg.dependencies.zustand ||= dependencies.zustand || '^5.0.15';
@@ -0,0 +1,93 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { relative, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
7
+ const cli = metadata.name;
8
+
9
+ /**
10
+ * The project's own gateway runtime (orionplace/runtime/marketplace.mjs): the
11
+ * sitemap is built with the code the running storefront uses.
12
+ */
13
+ async function projectRuntime(root) {
14
+ const path = resolve(root, 'orionplace/runtime/marketplace.mjs');
15
+ if (!existsSync(path)) {
16
+ throw new Error(`No orionplace/runtime/marketplace.mjs in ${root}. Run this in the storefront project (or pass --cwd).`);
17
+ }
18
+ const runtime = await import(pathToFileURL(path).href);
19
+ if (typeof runtime.rebuildMarketplaceSitemap !== 'function') {
20
+ throw new Error(`This project's runtime has no sitemap support. Run npx ${cli} update first.`);
21
+ }
22
+ return runtime;
23
+ }
24
+
25
+ /**
26
+ * The storefront reads SITEMAPS_DIR and ORIONPLACE_CACHE_DIR from the
27
+ * project's .env; the command uses the same folders. Variables already set
28
+ * in the shell win.
29
+ */
30
+ async function projectEnvironment(root) {
31
+ let source = '';
32
+ try { source = await readFile(resolve(root, '.env'), 'utf8'); }
33
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
34
+ for (const line of source.split(/\r?\n/)) {
35
+ const match = /^\s*(?:export\s+)?(SITEMAPS_DIR|ORIONPLACE_CACHE_DIR)\s*=\s*(.*?)\s*$/.exec(line);
36
+ if (!match || process.env[match[1]] !== undefined) continue;
37
+ const value = match[2].replace(/^(['"])(.*)\1$/, '$2');
38
+ if (value) process.env[match[1]] = resolve(root, value);
39
+ }
40
+ }
41
+
42
+ async function domainsFor(runtime, root, domain) {
43
+ if (domain) {
44
+ const normalized = runtime.normalizeFrontDomain(domain);
45
+ if (!normalized) throw new Error(`Invalid frontend domain: ${domain}`);
46
+ return [normalized];
47
+ }
48
+ const domains = await runtime.marketplaceDomains(root);
49
+ if (!domains.length) throw new Error(`No frontend domains yet. Add one: npx ${cli} add frontend <shop.example.com>`);
50
+ return domains;
51
+ }
52
+
53
+ function age(built, now = Date.now()) {
54
+ const minutes = Math.max(0, Math.round((now - Date.parse(built)) / 60_000));
55
+ return minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
56
+ }
57
+
58
+ /**
59
+ * `sitemap rebuild [domain]`: build the sitemap of every frontend domain (or
60
+ * one) now. `sitemap status [domain]`: show when each was built.
61
+ * @param {{cwd: string, action: string, domain?: string, log?: (line: string) => void}} options
62
+ * @returns {Promise<number>} exit code
63
+ */
64
+ export async function sitemapCommand({ cwd, action, domain, log = console.log }) {
65
+ if (!['rebuild', 'status'].includes(action)) {
66
+ throw new Error(`Use: npx ${cli} sitemap rebuild [domain] | sitemap status [domain]`);
67
+ }
68
+ const root = resolve(cwd);
69
+ await projectEnvironment(root);
70
+ const runtime = await projectRuntime(root);
71
+ const domains = await domainsFor(runtime, root, domain);
72
+ let failed = 0;
73
+ for (const name of domains) {
74
+ if (action === 'status') {
75
+ const sitemap = await runtime.readSitemapCache(name, root);
76
+ const stale = sitemap && Date.now() - Date.parse(sitemap.built) >= runtime.SITEMAP_LIFETIME;
77
+ log(sitemap
78
+ ? `${name}: built ${sitemap.built} (${age(sitemap.built)}), ${runtime.sitemapUrlCount(sitemap)} URLs${stale ? '; rebuilt at the next crawl' : ''}`
79
+ : `${name}: not built yet; built at the first /sitemap.xml request or with: npx ${cli} sitemap rebuild`);
80
+ continue;
81
+ }
82
+ const started = Date.now();
83
+ try {
84
+ const done = await runtime.rebuildMarketplaceSitemap(name, { root });
85
+ log(`${name}: ${done.urls} URLs (${done.pages} pages, ${done.publications} journal publications) in ${((Date.now() - started) / 1000).toFixed(1)} s`);
86
+ log(` saved ${relative(root, done.path)}; the running storefront serves it now at https://${name}/sitemap.xml`);
87
+ } catch (error) {
88
+ failed++;
89
+ log(`${name}: not rebuilt: ${error?.message ?? error}`);
90
+ }
91
+ }
92
+ return failed ? 1 : 0;
93
+ }