@orderlyshop/web-components 0.1.0-build.7045
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/AGENTS.md +110 -0
- package/README.md +685 -0
- package/bin/orderly-build-category-pages.mjs +160 -0
- package/bin/orderly-generate-category-pages.mjs +308 -0
- package/bin/orderly-hydrate-static-pages.mjs +595 -0
- package/bin/orderly-init-navigation.mjs +327 -0
- package/bin/orderly-init-shop.mjs +876 -0
- package/bin/orderly-init-taxonomy.mjs +2 -0
- package/bin/orderly-publish-site.mjs +342 -0
- package/custom-elements.json +505 -0
- package/dist/browser/orderly-web-components.define.global.js +3551 -0
- package/dist/browser/orderly-web-components.define.global.js.map +1 -0
- package/dist/browser/orderly-web-components.global.js +3551 -0
- package/dist/browser/orderly-web-components.global.js.map +1 -0
- package/dist/default-shop-DgX6uy10.d.ts +221 -0
- package/dist/default-shop.d.ts +6 -0
- package/dist/default-shop.js +762 -0
- package/dist/default-shop.js.map +1 -0
- package/dist/define-BNMhl19n.d.ts +9 -0
- package/dist/define.d.ts +2 -0
- package/dist/define.js +11094 -0
- package/dist/define.js.map +1 -0
- package/dist/index.d.ts +683 -0
- package/dist/index.js +11417 -0
- package/dist/index.js.map +1 -0
- package/dist/navigation.d.ts +61 -0
- package/dist/navigation.js +1125 -0
- package/dist/navigation.js.map +1 -0
- package/dist/query.d.ts +31 -0
- package/dist/query.js +115 -0
- package/dist/query.js.map +1 -0
- package/dist/registry-CPDecU3g.d.ts +6 -0
- package/dist/shop-BgQhGRzS.d.ts +173 -0
- package/dist/shop-query.d.ts +8 -0
- package/dist/shop-query.js +100 -0
- package/dist/shop-query.js.map +1 -0
- package/dist/shop.d.ts +8 -0
- package/dist/shop.js +11187 -0
- package/dist/shop.js.map +1 -0
- package/dist/stores.d.ts +46 -0
- package/dist/stores.js +145 -0
- package/dist/stores.js.map +1 -0
- package/dist/taxonomy.d.ts +35 -0
- package/dist/taxonomy.js +247 -0
- package/dist/taxonomy.js.map +1 -0
- package/dist/types-Bjez59Hr.d.ts +96 -0
- package/docs/components/README.md +708 -0
- package/docs/components/product-grid.md +182 -0
- package/docs/components/product-rail.md +174 -0
- package/examples/shop/README.md +72 -0
- package/examples/shop/package.json +28 -0
- package/examples/shop/src/category.html +20 -0
- package/examples/shop/src/checkout.html +21 -0
- package/examples/shop/src/forretningsbetingelser.html +81 -0
- package/examples/shop/src/includes/body-end.html +1 -0
- package/examples/shop/src/includes/body-start.html +3 -0
- package/examples/shop/src/includes/head.html +32 -0
- package/examples/shop/src/index.html +25 -0
- package/examples/shop/src/navigation.ts +154 -0
- package/examples/shop/src/product.html +24 -0
- package/examples/shop/src/templates/page-layouts.html +162 -0
- package/examples/shop/src/templates/shop-footer.html +76 -0
- package/examples/shop/tsconfig.json +32 -0
- package/examples/shop/vite.config.mjs +190 -0
- package/html-custom-data.json +279 -0
- package/package.json +118 -0
- package/server/README.md +111 -0
- package/server/apache/.htaccess +18 -0
- package/server/nginx/orderly-products.conf +24 -0
- package/server/node/product-snapshot-server.mjs +133 -0
- package/server/php/orderly-product.php +204 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { clone } from "@bufbuild/protobuf";
|
|
7
|
+
import { anyUnpack } from "@bufbuild/protobuf/wkt";
|
|
8
|
+
import {
|
|
9
|
+
SearchObjectSchema,
|
|
10
|
+
SearchQuerySchema
|
|
11
|
+
} from "@orderlyshop/core-client";
|
|
12
|
+
import { createOrderlyNodeClient } from "@orderlyshop/core-client/node";
|
|
13
|
+
import {
|
|
14
|
+
createCategoryNavigation,
|
|
15
|
+
flattenCategoryNavigation
|
|
16
|
+
} from "../dist/taxonomy.js";
|
|
17
|
+
import { mergeShopSearchQuery } from "../dist/shop-query.js";
|
|
18
|
+
|
|
19
|
+
const root = process.cwd();
|
|
20
|
+
const args = parseArgs(process.argv.slice(2));
|
|
21
|
+
|
|
22
|
+
if (args.help || args.h) {
|
|
23
|
+
printHelp();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const distDir = resolve(root, args.dist ?? "dist");
|
|
28
|
+
const navigationFile = resolve(root, args.navigation ?? args.taxonomy ?? defaultNavigationPath());
|
|
29
|
+
const shopQueryFile = resolve(root, args["shop-query"] ?? defaultShopQueryPath());
|
|
30
|
+
const exportName = args.export;
|
|
31
|
+
const shopQueryExportName = args["shop-query-export"] ?? "defaultQuery";
|
|
32
|
+
const pageRoot = args["page-root"] ?? "/categories/";
|
|
33
|
+
const distCategoryRoot = args["dist-categories-dir"] ?? pathRootFromPageRoot(pageRoot);
|
|
34
|
+
const siteTitle = args["site-title"] ?? defaultSiteTitle();
|
|
35
|
+
const productPage = args["product-page"] ?? "/product.html";
|
|
36
|
+
const productRoot = args["product-root"];
|
|
37
|
+
const baseUrl = args["base-url"] ?? process.env.VITE_ORDERLY_BASE_URL ?? process.env.ORDERLY_BASE_URL ?? "https://service.orderly.shop";
|
|
38
|
+
const protocol = args.protocol === "grpc" ? "grpc" : "grpc-web";
|
|
39
|
+
const productsPerCategory = positiveInteger(args["products-per-category"], 12);
|
|
40
|
+
const homeProductsPerCategory = positiveInteger(args["home-products-per-category"], productsPerCategory);
|
|
41
|
+
const imageBaseUrl = args["image-base-url"] ?? process.env.VITE_ORDERLY_IMAGE_BASE_URL ?? process.env.ORDERLY_IMAGE_BASE_URL ?? "https://orderlyproduction.azureedge.net/";
|
|
42
|
+
const dryRun = Boolean(args["dry-run"]);
|
|
43
|
+
const skipProducts = Boolean(args["skip-products"]);
|
|
44
|
+
const strictProducts = Boolean(args["strict-products"]);
|
|
45
|
+
|
|
46
|
+
if (!existsSync(distDir)) {
|
|
47
|
+
throw new Error(`Build output directory not found: ${relativePath(distDir)}`);
|
|
48
|
+
}
|
|
49
|
+
if (!existsSync(navigationFile)) {
|
|
50
|
+
throw new Error(`Navigation file not found: ${relativePath(navigationFile)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const navigationModule = await loadModule(navigationFile);
|
|
54
|
+
const definitions = navigationDefinitions(navigationModule, exportName);
|
|
55
|
+
if (!Array.isArray(definitions)) {
|
|
56
|
+
const expected = exportName ? `an array named ${exportName}` : "navigationDefinitions";
|
|
57
|
+
throw new Error(`${relativePath(navigationFile)} must export ${expected}.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultQuery = existsSync(shopQueryFile)
|
|
61
|
+
? await loadDefaultQuery(shopQueryFile, shopQueryExportName)
|
|
62
|
+
: undefined;
|
|
63
|
+
const navigation = createCategoryNavigation(definitions, {
|
|
64
|
+
pageRoot,
|
|
65
|
+
urlMode: "path"
|
|
66
|
+
});
|
|
67
|
+
const categories = flattenCategoryNavigation(navigation);
|
|
68
|
+
const categoryById = new Map(categories.map((category) => [category.id, category]));
|
|
69
|
+
const client = skipProducts || !baseUrl
|
|
70
|
+
? undefined
|
|
71
|
+
: createOrderlyNodeClient({ baseUrl, protocol });
|
|
72
|
+
const productCache = new Map();
|
|
73
|
+
let updatedPages = 0;
|
|
74
|
+
let hydratedProducts = 0;
|
|
75
|
+
|
|
76
|
+
const homeFile = resolve(distDir, "index.html");
|
|
77
|
+
if (existsSync(homeFile)) {
|
|
78
|
+
const homeCategories = navigation;
|
|
79
|
+
const homeProducts = new Map();
|
|
80
|
+
for (const category of homeCategories) {
|
|
81
|
+
const products = await productsForCategory(category, homeProductsPerCategory);
|
|
82
|
+
hydratedProducts += products.length;
|
|
83
|
+
homeProducts.set(category.id, products);
|
|
84
|
+
}
|
|
85
|
+
const html = readFileSync(homeFile, "utf8");
|
|
86
|
+
const next = hydrateHomeHtml(html, homeCategories, homeProducts);
|
|
87
|
+
if (next !== html) {
|
|
88
|
+
writePage(homeFile, next);
|
|
89
|
+
updatedPages += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const category of categories) {
|
|
94
|
+
const file = resolve(distDir, distCategoryRoot, ...category.metadata.pathSegments, "index.html");
|
|
95
|
+
if (!existsSync(file)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const products = await productsForCategory(category, productsPerCategory);
|
|
99
|
+
hydratedProducts += products.length;
|
|
100
|
+
const html = readFileSync(file, "utf8");
|
|
101
|
+
const next = hydrateCategoryHtml(html, category, products);
|
|
102
|
+
if (next !== html) {
|
|
103
|
+
writePage(file, next);
|
|
104
|
+
updatedPages += 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const productSuffix = skipProducts
|
|
109
|
+
? "Product snapshots were skipped."
|
|
110
|
+
: `${hydratedProducts} product snapshot${hydratedProducts === 1 ? "" : "s"} embedded.`;
|
|
111
|
+
console.log(`Hydrated ${updatedPages} static page${updatedPages === 1 ? "" : "s"}. ${productSuffix}`);
|
|
112
|
+
|
|
113
|
+
function writePage(file, html) {
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
console.log(`Would hydrate ${relativePath(file)}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(file, html, "utf8");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function productsForCategory(category, limit) {
|
|
122
|
+
if (!client || limit <= 0) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const cacheKey = `${category.id}:${limit}`;
|
|
126
|
+
if (productCache.has(cacheKey)) {
|
|
127
|
+
return productCache.get(cacheKey);
|
|
128
|
+
}
|
|
129
|
+
const products = await fetchProducts(category, limit);
|
|
130
|
+
productCache.set(cacheKey, products);
|
|
131
|
+
return products;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchProducts(category, limit) {
|
|
135
|
+
const request = mergeShopSearchQuery(clone(SearchQuerySchema, category.metadata.query), defaultQuery);
|
|
136
|
+
request.Continuation = undefined;
|
|
137
|
+
try {
|
|
138
|
+
const result = await client.services.searchService.search(request);
|
|
139
|
+
return result.data
|
|
140
|
+
.map((item) => anyUnpack(item, SearchObjectSchema))
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.slice(0, limit);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
if (strictProducts) {
|
|
146
|
+
throw new Error(`Could not hydrate products for ${category.id}: ${message}`, { cause: error });
|
|
147
|
+
}
|
|
148
|
+
console.warn(`Could not hydrate products for ${category.id}: ${message}`);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hydrateHomeHtml(html, categories, productsByCategory) {
|
|
154
|
+
const title = readAttribute(html, "orderly-home-page", "title") ?? siteTitle;
|
|
155
|
+
const eyebrow = readAttribute(html, "orderly-home-page", "eyebrow");
|
|
156
|
+
const fallback = renderHomeFallback({ title, eyebrow, categories, productsByCategory });
|
|
157
|
+
return injectComponentFallback(html, "orderly-home-page", fallback);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hydrateCategoryHtml(html, category, products) {
|
|
161
|
+
let next = html;
|
|
162
|
+
next = next.replace(/<title>.*?<\/title>/, `<title>${escapeHtml(category.label)} | ${escapeHtml(siteTitle)}</title>`);
|
|
163
|
+
next = injectDescription(next, category.metadata.description);
|
|
164
|
+
next = injectCanonical(next, category.href);
|
|
165
|
+
const fallback = renderCategoryFallback(category, products);
|
|
166
|
+
return injectComponentFallback(next, "orderly-category-page", fallback);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderHomeFallback({ title, eyebrow, categories, productsByCategory }) {
|
|
170
|
+
return `
|
|
171
|
+
<section slot="content-before" class="orderly-ssr-home" data-orderly-ssr-fallback data-orderly-page="home">
|
|
172
|
+
<header class="orderly-ssr-home__intro">
|
|
173
|
+
${eyebrow ? `<p class="orderly-ssr-home__eyebrow">${escapeHtml(eyebrow)}</p>` : ""}
|
|
174
|
+
<h1>${escapeHtml(title)}</h1>
|
|
175
|
+
</header>
|
|
176
|
+
<div class="orderly-ssr-home__rails">
|
|
177
|
+
${categories.map((category) => renderHomeRail(category, productsByCategory.get(category.id) ?? [])).join("\n")}
|
|
178
|
+
</div>
|
|
179
|
+
</section>
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderHomeRail(category, products) {
|
|
184
|
+
return `
|
|
185
|
+
<section class="orderly-ssr-rail" data-orderly-category="${escapeAttribute(category.id)}">
|
|
186
|
+
<header class="orderly-ssr-rail__header">
|
|
187
|
+
<h2><a href="${escapeAttribute(category.href ?? "#")}">${escapeHtml(category.label)}</a></h2>
|
|
188
|
+
${category.metadata.description ? `<p>${escapeHtml(category.metadata.description)}</p>` : ""}
|
|
189
|
+
</header>
|
|
190
|
+
${renderProducts(products, "orderly-ssr-rail__items")}
|
|
191
|
+
</section>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderCategoryFallback(category, products) {
|
|
195
|
+
return `
|
|
196
|
+
<section slot="content-before" class="orderly-ssr-category" data-orderly-ssr-fallback data-orderly-category="${escapeAttribute(category.id)}">
|
|
197
|
+
${renderBreadcrumb(category)}
|
|
198
|
+
<header class="orderly-ssr-category__hero">
|
|
199
|
+
${category.metadata.heroImage ? `<img src="${escapeAttribute(category.metadata.heroImage)}" alt="" loading="eager">` : ""}
|
|
200
|
+
<div class="orderly-ssr-category__text">
|
|
201
|
+
<p class="orderly-ssr-category__eyebrow">${escapeHtml(categoryLabelPath(category).join(" / "))}</p>
|
|
202
|
+
<h1 data-orderly-field="title">${escapeHtml(category.label)}</h1>
|
|
203
|
+
<p data-orderly-field="description">${escapeHtml(category.metadata.description)}</p>
|
|
204
|
+
</div>
|
|
205
|
+
</header>
|
|
206
|
+
${renderProducts(products, "orderly-ssr-product-grid")}
|
|
207
|
+
</section>
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderBreadcrumb(category) {
|
|
212
|
+
if (category.metadata.pathSegments.length <= 1) {
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
const paths = category.metadata.pathSegments;
|
|
216
|
+
return `
|
|
217
|
+
<nav class="orderly-ssr-breadcrumb" aria-label="Brødkrumme">
|
|
218
|
+
${paths.map((segment, index) => {
|
|
219
|
+
const id = paths.slice(0, index + 1).join("/");
|
|
220
|
+
const label = categoryById.get(id)?.label ?? segment;
|
|
221
|
+
return `<a href="${escapeAttribute(`${pageRoot}${id}/`)}">${escapeHtml(label)}</a>`;
|
|
222
|
+
}).join("<span>/</span>")}
|
|
223
|
+
</nav>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function categoryLabelPath(category) {
|
|
227
|
+
return category.metadata.pathSegments.map((segment, index) => {
|
|
228
|
+
const id = category.metadata.pathSegments.slice(0, index + 1).join("/");
|
|
229
|
+
return categoryById.get(id)?.label ?? segment;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function renderProducts(products, className) {
|
|
234
|
+
if (products.length === 0) {
|
|
235
|
+
return "";
|
|
236
|
+
}
|
|
237
|
+
return `
|
|
238
|
+
<div class="${className}" data-orderly-products>
|
|
239
|
+
${products.map(renderProduct).join("\n")}
|
|
240
|
+
</div>`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function renderProduct(product) {
|
|
244
|
+
const title = product.Title || "Produkt";
|
|
245
|
+
const brand = normalizedBrand(product.Brand);
|
|
246
|
+
const image = firstImageUrl(product);
|
|
247
|
+
const href = productHref(product);
|
|
248
|
+
const description = product.Description?.trim();
|
|
249
|
+
const sku = product.SKU?.Value?.trim();
|
|
250
|
+
return `
|
|
251
|
+
<article class="orderly-ssr-product" data-orderly-product itemscope itemtype="https://schema.org/Product">
|
|
252
|
+
${href ? `<link itemprop="url" href="${escapeAttribute(href)}">` : ""}
|
|
253
|
+
${image ? `<link itemprop="image" href="${escapeAttribute(image)}">` : ""}
|
|
254
|
+
${description ? `<meta itemprop="description" content="${escapeAttribute(description)}">` : ""}
|
|
255
|
+
${sku ? `<meta itemprop="sku" content="${escapeAttribute(sku)}">` : ""}
|
|
256
|
+
${href ? `<a class="orderly-ssr-product__link" href="${escapeAttribute(href)}">` : ""}
|
|
257
|
+
<div class="orderly-ssr-product__image">
|
|
258
|
+
${image ? `<img src="${escapeAttribute(image)}" alt="${escapeAttribute(title)}" loading="lazy">` : ""}
|
|
259
|
+
</div>
|
|
260
|
+
<div class="orderly-ssr-product__text">
|
|
261
|
+
<h3 data-orderly-field="title" itemprop="name">${escapeHtml(title)}</h3>
|
|
262
|
+
${brand ? `<p data-orderly-field="brand" itemprop="brand" itemscope itemtype="https://schema.org/Brand"><span itemprop="name">${escapeHtml(brand)}</span></p>` : ""}
|
|
263
|
+
${renderOffer(product, href)}
|
|
264
|
+
</div>
|
|
265
|
+
${href ? "</a>" : ""}
|
|
266
|
+
</article>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderOffer(product, href) {
|
|
270
|
+
const price = schemaPrice(product.Price);
|
|
271
|
+
const currency = schemaCurrency(product.Price);
|
|
272
|
+
const availability = product.Active ? "https://schema.org/InStock" : "";
|
|
273
|
+
if (!price) {
|
|
274
|
+
return `<p data-orderly-field="price">${escapeHtml(formatCredit(product.Price))}</p>`;
|
|
275
|
+
}
|
|
276
|
+
return `<p data-orderly-field="price" itemprop="offers" itemscope itemtype="https://schema.org/Offer">
|
|
277
|
+
${href ? `<link itemprop="url" href="${escapeAttribute(href)}">` : ""}
|
|
278
|
+
${currency ? `<meta itemprop="priceCurrency" content="${escapeAttribute(currency)}">` : ""}
|
|
279
|
+
<meta itemprop="price" content="${escapeAttribute(price)}">
|
|
280
|
+
${availability ? `<link itemprop="availability" href="${escapeAttribute(availability)}">` : ""}
|
|
281
|
+
${escapeHtml(formatCredit(product.Price))}
|
|
282
|
+
</p>`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function productHref(product) {
|
|
286
|
+
const shareUrl = compactProductShareUrl(product.ShareURL?.URL);
|
|
287
|
+
if (!shareUrl) {
|
|
288
|
+
return "";
|
|
289
|
+
}
|
|
290
|
+
if (productRoot) {
|
|
291
|
+
return joinUrl(productRoot, shareUrl.replace(/\.html$/i, ""));
|
|
292
|
+
}
|
|
293
|
+
return `${productPage}#url=${encodeURIComponent(shareUrl)}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function compactProductShareUrl(url) {
|
|
297
|
+
if (!url) {
|
|
298
|
+
return "";
|
|
299
|
+
}
|
|
300
|
+
return url.replace(/^https:\/\/orderly\.shop\//i, "");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function firstImageUrl(product) {
|
|
304
|
+
return storedImageUrl(product.Images?.[0]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function storedImageUrl(image) {
|
|
308
|
+
if (!image) {
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
const candidate = image;
|
|
312
|
+
for (const key of ["URL", "Url", "url", "ThumbnailUrl", "thumbnailUrl"]) {
|
|
313
|
+
const value = candidate[key];
|
|
314
|
+
if (typeof value === "string" && value.length > 0) {
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (image.ImageDataBase64) {
|
|
319
|
+
return `data:image/jpeg;base64,${image.ImageDataBase64}`;
|
|
320
|
+
}
|
|
321
|
+
if (/^https?:\/\//i.test(image.Name) || image.Name.startsWith("/")) {
|
|
322
|
+
return image.Name;
|
|
323
|
+
}
|
|
324
|
+
if (!image.Name) {
|
|
325
|
+
return "";
|
|
326
|
+
}
|
|
327
|
+
const imageName = image.Name.toLowerCase();
|
|
328
|
+
const path = image.IsAsset || imageName.includes("cms") || imageName.startsWith("images")
|
|
329
|
+
? "objects"
|
|
330
|
+
: "thumbnails";
|
|
331
|
+
return joinUrl(imageBaseUrl, path, image.Name);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function formatCredit(credit) {
|
|
335
|
+
if (!credit) {
|
|
336
|
+
return "";
|
|
337
|
+
}
|
|
338
|
+
const amount = Number(credit.Amount ?? 0);
|
|
339
|
+
const currency = credit.Currency || "";
|
|
340
|
+
const prefix = currency.toUpperCase() === "DKK" ? "kr." : currency.trim();
|
|
341
|
+
const value = amount.toLocaleString("da-DK", {
|
|
342
|
+
minimumFractionDigits: 2,
|
|
343
|
+
maximumFractionDigits: 2
|
|
344
|
+
});
|
|
345
|
+
return `${prefix ? `${prefix} ` : ""}${value}`.trim();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function schemaPrice(credit) {
|
|
349
|
+
if (!credit || !Number.isFinite(credit.Amount)) {
|
|
350
|
+
return "";
|
|
351
|
+
}
|
|
352
|
+
return credit.Amount.toFixed(2);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function schemaCurrency(credit) {
|
|
356
|
+
const currency = credit?.Currency?.trim();
|
|
357
|
+
return currency ? currency.toUpperCase() : "";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function normalizedBrand(brand) {
|
|
361
|
+
const value = brand?.trim();
|
|
362
|
+
if (!value || value.toLowerCase() === "ukendt" || value.toUpperCase() === "DVD") {
|
|
363
|
+
return "";
|
|
364
|
+
}
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function injectComponentFallback(html, tagName, fallback) {
|
|
369
|
+
const cleaned = stripExistingFallback(html);
|
|
370
|
+
const pattern = new RegExp(`<${escapeRegExp(tagName)}\\b([^>]*)>`, "i");
|
|
371
|
+
if (!pattern.test(cleaned)) {
|
|
372
|
+
return cleaned;
|
|
373
|
+
}
|
|
374
|
+
return cleaned.replace(pattern, (match, attributes) => {
|
|
375
|
+
const hydratedAttributes = /\sdata-orderly-ssr(?:\s|=|$)/.test(attributes)
|
|
376
|
+
? attributes
|
|
377
|
+
: `${attributes} data-orderly-ssr`;
|
|
378
|
+
return `<${tagName}${hydratedAttributes}>\n <!-- orderly-ssr-fallback-start -->${fallback} <!-- orderly-ssr-fallback-end -->`;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function stripExistingFallback(html) {
|
|
383
|
+
return html.replace(/\s*<!-- orderly-ssr-fallback-start -->[\s\S]*?<!-- orderly-ssr-fallback-end -->\s*/g, "\n");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function injectDescription(html, description) {
|
|
387
|
+
const meta = `<meta name="description" content="${escapeAttribute(description)}">`;
|
|
388
|
+
if (/<meta\s+name=["']description["'][^>]*>/i.test(html)) {
|
|
389
|
+
return html.replace(/<meta\s+name=["']description["'][^>]*>/i, meta);
|
|
390
|
+
}
|
|
391
|
+
if (html.includes("<!-- orderly-head-includes -->")) {
|
|
392
|
+
return html.replace("<!-- orderly-head-includes -->", `<!-- orderly-head-includes -->\n ${meta}`);
|
|
393
|
+
}
|
|
394
|
+
return html.replace("</head>", ` ${meta}\n </head>`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function injectCanonical(html, href) {
|
|
398
|
+
if (!href) {
|
|
399
|
+
return html;
|
|
400
|
+
}
|
|
401
|
+
const canonical = `<link rel="canonical" href="${escapeAttribute(href)}">`;
|
|
402
|
+
if (/<link\s+rel=["']canonical["'][^>]*>/i.test(html)) {
|
|
403
|
+
return html.replace(/<link\s+rel=["']canonical["'][^>]*>/i, canonical);
|
|
404
|
+
}
|
|
405
|
+
if (html.includes("<!-- orderly-head-includes -->")) {
|
|
406
|
+
return html.replace("<!-- orderly-head-includes -->", `<!-- orderly-head-includes -->\n ${canonical}`);
|
|
407
|
+
}
|
|
408
|
+
return html.replace("</head>", ` ${canonical}\n </head>`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readAttribute(html, tagName, attribute) {
|
|
412
|
+
const tag = html.match(new RegExp(`<${escapeRegExp(tagName)}\\b([^>]*)>`, "i"))?.[1];
|
|
413
|
+
if (!tag) {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
const match = tag.match(new RegExp(`${escapeRegExp(attribute)}=["']([^"']*)["']`, "i"));
|
|
417
|
+
return match?.[1] ? decodeHtml(match[1]) : undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function loadDefaultQuery(file, name) {
|
|
421
|
+
const module = await loadModule(file);
|
|
422
|
+
const query = module[name];
|
|
423
|
+
if (query === undefined || query === null) {
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
return clone(SearchQuerySchema, query);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function loadModule(file) {
|
|
430
|
+
const extension = extname(file).toLowerCase();
|
|
431
|
+
if (extension === ".ts" || extension === ".tsx") {
|
|
432
|
+
return loadTypeScriptModule(file);
|
|
433
|
+
}
|
|
434
|
+
return import(pathToFileURL(file).href);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function loadTypeScriptModule(file) {
|
|
438
|
+
const require = createRequire(resolve(root, "package.json"));
|
|
439
|
+
let typescript;
|
|
440
|
+
try {
|
|
441
|
+
typescript = require("typescript");
|
|
442
|
+
} catch (error) {
|
|
443
|
+
throw new Error(`TypeScript files require the current shop project to install typescript. Could not load typescript while reading ${relativePath(file)}.`, { cause: error });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const output = typescript.transpileModule(readFileSync(file, "utf8"), {
|
|
447
|
+
fileName: file,
|
|
448
|
+
compilerOptions: {
|
|
449
|
+
module: typescript.ModuleKind.ES2022,
|
|
450
|
+
target: typescript.ScriptTarget.ES2022,
|
|
451
|
+
esModuleInterop: true,
|
|
452
|
+
importsNotUsedAsValues: typescript.ImportsNotUsedAsValues?.Remove
|
|
453
|
+
}
|
|
454
|
+
}).outputText;
|
|
455
|
+
const temporaryFile = resolve(dirname(file), `.orderly-${basename(file, extname(file))}-${process.pid}-${Date.now()}.mjs`);
|
|
456
|
+
writeFileSync(temporaryFile, output, "utf8");
|
|
457
|
+
try {
|
|
458
|
+
return await import(pathToFileURL(temporaryFile).href);
|
|
459
|
+
} finally {
|
|
460
|
+
rmSync(temporaryFile, { force: true });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function defaultNavigationPath() {
|
|
465
|
+
for (const candidate of ["src/navigation.ts", "src/navigation.js", "src/navigation.mjs", "navigation.ts", "navigation.js", "navigation.mjs", "src/taxonomy.ts", "src/taxonomy.js", "src/taxonomy.mjs", "taxonomy.ts", "taxonomy.js", "taxonomy.mjs"]) {
|
|
466
|
+
if (existsSync(resolve(root, candidate))) {
|
|
467
|
+
return candidate;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return "src/navigation.ts";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function defaultShopQueryPath() {
|
|
474
|
+
for (const candidate of ["src/shop-query.ts", "src/shop-query.js", "src/shop-query.mjs", "shop-query.ts", "shop-query.js", "shop-query.mjs"]) {
|
|
475
|
+
if (existsSync(resolve(root, candidate))) {
|
|
476
|
+
return candidate;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return "src/shop-query.ts";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function navigationDefinitions(module, name) {
|
|
483
|
+
if (name) {
|
|
484
|
+
return module[name];
|
|
485
|
+
}
|
|
486
|
+
return module.navigationDefinitions ?? module.categoryDefinitions;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function pathRootFromPageRoot(value) {
|
|
490
|
+
const normalized = String(value || "/categories/").replace(/^\/+|\/+$/g, "");
|
|
491
|
+
return normalized || "categories";
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function defaultSiteTitle() {
|
|
495
|
+
const packageFile = resolve(root, "package.json");
|
|
496
|
+
if (!existsSync(packageFile)) {
|
|
497
|
+
return "Shop";
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const packageJson = JSON.parse(readFileSync(packageFile, "utf8"));
|
|
501
|
+
return packageJson.displayName ?? packageJson.description ?? packageJson.name ?? "Shop";
|
|
502
|
+
} catch {
|
|
503
|
+
return "Shop";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function positiveInteger(value, fallback) {
|
|
508
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
509
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function joinUrl(baseUrl, ...parts) {
|
|
513
|
+
const rootUrl = String(baseUrl).endsWith("/") ? String(baseUrl) : `${baseUrl}/`;
|
|
514
|
+
return new URL(parts
|
|
515
|
+
.filter(Boolean)
|
|
516
|
+
.map((part) => String(part).replace(/^\/+|\/+$/g, ""))
|
|
517
|
+
.join("/"), rootUrl).toString();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function parseArgs(values) {
|
|
521
|
+
const parsed = {};
|
|
522
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
523
|
+
const value = values[index];
|
|
524
|
+
if (!value.startsWith("--")) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const [name, inlineValue] = value.slice(2).split("=", 2);
|
|
528
|
+
if (inlineValue !== undefined) {
|
|
529
|
+
parsed[name] = inlineValue;
|
|
530
|
+
} else if (values[index + 1] && !values[index + 1].startsWith("--")) {
|
|
531
|
+
parsed[name] = values[index + 1];
|
|
532
|
+
index += 1;
|
|
533
|
+
} else {
|
|
534
|
+
parsed[name] = true;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return parsed;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function relativePath(file) {
|
|
541
|
+
return relative(root, file) || ".";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function escapeHtml(value) {
|
|
545
|
+
return String(value ?? "")
|
|
546
|
+
.replace(/&/g, "&")
|
|
547
|
+
.replace(/</g, "<")
|
|
548
|
+
.replace(/>/g, ">");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function escapeAttribute(value) {
|
|
552
|
+
return escapeHtml(value).replace(/"/g, """);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function decodeHtml(value) {
|
|
556
|
+
return value
|
|
557
|
+
.replace(/"/g, "\"")
|
|
558
|
+
.replace(/>/g, ">")
|
|
559
|
+
.replace(/</g, "<")
|
|
560
|
+
.replace(/&/g, "&");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function escapeRegExp(value) {
|
|
564
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function printHelp() {
|
|
568
|
+
console.log(`Usage: orderly-hydrate-static-pages [options]
|
|
569
|
+
|
|
570
|
+
Hydrate built static storefront pages with SEO-friendly category and homepage fallback HTML.
|
|
571
|
+
|
|
572
|
+
Options:
|
|
573
|
+
--dist <dir> Build output directory. Defaults to dist.
|
|
574
|
+
--navigation <file> Navigation module. Defaults to src/navigation.ts or src/navigation.js when present.
|
|
575
|
+
--taxonomy <file> Backward-compatible alias for --navigation.
|
|
576
|
+
--export <name> Export name for navigation definitions. Defaults to navigationDefinitions.
|
|
577
|
+
--shop-query <file> Optional shop-wide SearchQuery module. Defaults to src/shop-query.ts when present.
|
|
578
|
+
--shop-query-export <name> Export name for shop query. Defaults to defaultQuery.
|
|
579
|
+
--page-root <path> Category URL root. Defaults to /categories/.
|
|
580
|
+
--dist-categories-dir <dir> Category directory inside dist. Defaults to page-root without slashes.
|
|
581
|
+
--site-title <title> Site title suffix used in generated category <title> tags.
|
|
582
|
+
--base-url <url> Orderly backend URL used for product snapshots. Also reads VITE_ORDERLY_BASE_URL. Defaults to https://service.orderly.shop.
|
|
583
|
+
--protocol <grpc|grpc-web> Backend protocol for snapshots. Defaults to grpc-web.
|
|
584
|
+
--products-per-category <n> Product cards embedded per category page. Defaults to 12.
|
|
585
|
+
--home-products-per-category <n>
|
|
586
|
+
Product cards embedded per homepage rail. Defaults to --products-per-category.
|
|
587
|
+
--product-page <path> Product page used for snapshot links. Defaults to /product.html.
|
|
588
|
+
--product-root <path> Optional path-style product root instead of product.html#url=...
|
|
589
|
+
--image-base-url <url> StoredImage CDN prefix. Defaults to Orderly production CDN.
|
|
590
|
+
--skip-products Hydrate category/home text only, without backend product calls.
|
|
591
|
+
--strict-products Fail when a product snapshot call fails.
|
|
592
|
+
--dry-run Print files that would change without writing.
|
|
593
|
+
--help, -h Show this help.
|
|
594
|
+
`);
|
|
595
|
+
}
|