orionplace-nuxt 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.
- package/README.md +23 -1
- package/bin/orionplace-nuxt.mjs +7 -0
- package/package.json +1 -1
- package/runtime/domain.mjs +6 -2
- package/runtime/marketplace.mjs +519 -3
- package/runtime/seo.mjs +17 -0
- package/src/module.mjs +6 -0
- package/src/project-readme.mjs +23 -2
- package/src/setup.mjs +2 -1
- package/src/sitemap.mjs +93 -0
- package/templates/app/components/orionplace/account/ProfileDetailsForm.vue +12 -2
- package/templates/app/components/orionplace/cart/CartCheckoutDetails.README.md +4 -0
- package/templates/app/components/orionplace/cart/CartCheckoutDetails.vue +10 -0
- package/templates/app/components/orionplace/catalog/StorefrontCatalog.vue +2 -2
- package/templates/app/composables/useOrionplacePageSeo.ts +35 -6
- package/templates/app/composables/useOrionplaceProductSearch.ts +115 -11
- package/templates/app/interfaces/orionplace/accounts/IProfileData.ts +2 -0
- package/templates/app/interfaces/orionplace/appBase/IApploadUserProfile.ts +2 -0
- package/templates/app/pages/browse.vue +8 -1
- package/templates/app/pages/category/[id].vue +12 -4
- package/templates/app/pages/featured.vue +11 -3
- package/templates/app/utils/orionplace/cartLabels.ts +2 -0
- package/templates/app/utils/orionplace/pageSeo.ts +68 -0
- package/templates/app/utils/orionplace/productListLocation.ts +130 -0
- package/templates/app/utils/orionplace/profile.ts +12 -0
- package/templates/app/utils/orionplace/profileLabels.ts +2 -0
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
|
|
@@ -320,6 +320,8 @@ npx orionplace-nuxt add frontend shop.example.com
|
|
|
320
320
|
npx orionplace-nuxt add frontend www.shop.example.com
|
|
321
321
|
npx orionplace-nuxt remove frontend www.shop.example.com
|
|
322
322
|
npx orionplace-nuxt set mimic shop.example.com
|
|
323
|
+
npx orionplace-nuxt sitemap rebuild [shop.example.com]
|
|
324
|
+
npx orionplace-nuxt sitemap status
|
|
323
325
|
sudo npx orionplace-nuxt install
|
|
324
326
|
npx orionplace-nuxt status
|
|
325
327
|
sudo npx orionplace-nuxt restart
|
|
@@ -336,8 +338,15 @@ npm run orionplace:remove-frontend -- www.shop.example.com
|
|
|
336
338
|
npm run orionplace:mimic -- shop.example.com
|
|
337
339
|
npm run orionplace:help
|
|
338
340
|
npm run orionplace:update
|
|
341
|
+
npm run orionplace:sitemap
|
|
339
342
|
```
|
|
340
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
|
+
|
|
341
350
|
`npm set` belongs to npm's own configuration system; use the commands above.
|
|
342
351
|
The setup commands configure local integration files. They do not provision a
|
|
343
352
|
backend, change your account or create DNS records. `install` deploys the
|
|
@@ -1012,6 +1021,19 @@ replace options, colors, counts or price bounds with narrowed responses. New
|
|
|
1012
1021
|
queries, categories and AppLoad refreshes reset that scope; Clear keeps
|
|
1013
1022
|
its original selectors. Superseded requests are cancelled and ignored.
|
|
1014
1023
|
|
|
1024
|
+
Category, search, featured and seller lists keep their result page and
|
|
1025
|
+
filters in the URL as `?page=2&filters={...}` (JSON of the filter selection).
|
|
1026
|
+
The composable replaces the current history entry on each change, so the
|
|
1027
|
+
browser's Back button from a product, and a reload, open the same list, while
|
|
1028
|
+
Back from the list still leaves it in one step. Page loads read the same
|
|
1029
|
+
query through `productListLocationFromQuery()` in
|
|
1030
|
+
`app/utils/orionplace/productListLocation.ts`, which accepts only plain filter
|
|
1031
|
+
values within fixed limits. A list opened this way on a later page or with
|
|
1032
|
+
filters also requests the unfiltered first page once, so the filter panel
|
|
1033
|
+
keeps every choice. Pass `rememberInAddress: false` to keep a list out of the
|
|
1034
|
+
URL. The React and Svelte packages do the same in `useProductSearch()` and
|
|
1035
|
+
`useOrionplaceProductSearch()`.
|
|
1036
|
+
|
|
1015
1037
|
`SearchFilters` supplies cumulative ID arrays and scalar price, discount and
|
|
1016
1038
|
rating fields. Currency-keyed `selects.measures.min_price` and `max_price` supply
|
|
1017
1039
|
the price range; Apply or a committed slider change emits `price_from`,
|
package/bin/orionplace-nuxt.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
package/runtime/domain.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// Other server code can read event.context.marketplaceDomain for canonical URLs.
|
|
2
|
-
|
|
2
|
+
// Pages under ORIONPLACE_NOINDEX_PATHS answer with `X-Robots-Tag: noindex, follow`.
|
|
3
|
+
import { defineEventHandler, getRequestURL, setResponseHeader, toWebRequest } from 'h3';
|
|
3
4
|
import { useRuntimeConfig } from '#imports';
|
|
4
|
-
import { resolveFrontDomain } from './marketplace.mjs';
|
|
5
|
+
import { marketplaceNoindexPath, resolveFrontDomain } from './marketplace.mjs';
|
|
5
6
|
|
|
6
7
|
export default defineEventHandler((event) => {
|
|
7
8
|
event.context.marketplaceDomain = resolveFrontDomain(
|
|
8
9
|
toWebRequest(event),
|
|
9
10
|
process.env.MIMIC_FRONT_DOMAIN ?? useRuntimeConfig(event).mimicFrontDomain,
|
|
10
11
|
);
|
|
12
|
+
if (marketplaceNoindexPath(getRequestURL(event).pathname)) {
|
|
13
|
+
setResponseHeader(event, 'X-Robots-Tag', 'noindex, follow');
|
|
14
|
+
}
|
|
11
15
|
});
|
package/runtime/marketplace.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
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
|
+
}
|
package/runtime/seo.mjs
ADDED
|
@@ -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
|
},
|