901 lines
31 KiB
JavaScript
901 lines
31 KiB
JavaScript
require("@babel/register")({
|
||
presets: [
|
||
["@babel/preset-env", { targets: { node: "current" } }],
|
||
"@babel/preset-react",
|
||
],
|
||
extensions: [".js", ".jsx"],
|
||
ignore: [/node_modules/],
|
||
});
|
||
|
||
// Minimal globals for socket.io-client only - no JSDOM to avoid interference
|
||
global.window = {}; // Minimal window object for productCache
|
||
global.navigator = { userAgent: "node.js" };
|
||
// Use Node.js URL constructor for React Router compatibility
|
||
global.URL = require("url").URL;
|
||
global.Blob = class MockBlob {
|
||
constructor(data, options) {
|
||
this.data = data;
|
||
this.type = options?.type || "";
|
||
}
|
||
};
|
||
|
||
class CategoryService {
|
||
constructor() {
|
||
this.get = this.get.bind(this);
|
||
}
|
||
|
||
getSync(categoryId, language = "de") {
|
||
const cacheKey = `${categoryId}_${language}`;
|
||
return null;
|
||
}
|
||
|
||
async get(categoryId, language = "de") {
|
||
const cacheKey = `${categoryId}_${language}`;
|
||
return null;
|
||
}
|
||
}
|
||
global.window.categoryService = new CategoryService();
|
||
|
||
|
||
// Import modules
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const React = require("react");
|
||
const io = require("socket.io-client");
|
||
const os = require("os");
|
||
const { Worker, isMainThread, parentPort, workerData } = require("worker_threads");
|
||
|
||
// Initialize i18n for prerendering with German as default
|
||
const i18n = require("i18next");
|
||
const { initReactI18next } = require("react-i18next");
|
||
|
||
// Import all translation files
|
||
const translationDE = require("./src/i18n/locales/de/translation.js").default;
|
||
const translationEN = require("./src/i18n/locales/en/translation.js").default;
|
||
const translationAR = require("./src/i18n/locales/ar/translation.js").default;
|
||
const translationBG = require("./src/i18n/locales/bg/translation.js").default;
|
||
const translationCS = require("./src/i18n/locales/cs/translation.js").default;
|
||
const translationEL = require("./src/i18n/locales/el/translation.js").default;
|
||
const translationES = require("./src/i18n/locales/es/translation.js").default;
|
||
const translationFR = require("./src/i18n/locales/fr/translation.js").default;
|
||
const translationHR = require("./src/i18n/locales/hr/translation.js").default;
|
||
const translationHU = require("./src/i18n/locales/hu/translation.js").default;
|
||
const translationIT = require("./src/i18n/locales/it/translation.js").default;
|
||
const translationPL = require("./src/i18n/locales/pl/translation.js").default;
|
||
const translationRO = require("./src/i18n/locales/ro/translation.js").default;
|
||
const translationRU = require("./src/i18n/locales/ru/translation.js").default;
|
||
const translationSK = require("./src/i18n/locales/sk/translation.js").default;
|
||
const translationSL = require("./src/i18n/locales/sl/translation.js").default;
|
||
const translationSR = require("./src/i18n/locales/sr/translation.js").default;
|
||
const translationSV = require("./src/i18n/locales/sv/translation.js").default;
|
||
const translationTR = require("./src/i18n/locales/tr/translation.js").default;
|
||
const translationUK = require("./src/i18n/locales/uk/translation.js").default;
|
||
const translationZH = require("./src/i18n/locales/zh/translation.js").default;
|
||
|
||
// Initialize i18n for prerendering
|
||
i18n
|
||
.use(initReactI18next)
|
||
.init({
|
||
resources: {
|
||
de: { translation: translationDE },
|
||
en: { translation: translationEN },
|
||
ar: { translation: translationAR },
|
||
bg: { translation: translationBG },
|
||
cs: { translation: translationCS },
|
||
el: { translation: translationEL },
|
||
es: { translation: translationES },
|
||
fr: { translation: translationFR },
|
||
hr: { translation: translationHR },
|
||
hu: { translation: translationHU },
|
||
it: { translation: translationIT },
|
||
pl: { translation: translationPL },
|
||
ro: { translation: translationRO },
|
||
ru: { translation: translationRU },
|
||
sk: { translation: translationSK },
|
||
sl: { translation: translationSL },
|
||
sr: { translation: translationSR },
|
||
sv: { translation: translationSV },
|
||
tr: { translation: translationTR },
|
||
uk: { translation: translationUK },
|
||
zh: { translation: translationZH }
|
||
},
|
||
lng: 'de', // Default to German for prerendering
|
||
fallbackLng: 'de',
|
||
debug: false,
|
||
interpolation: {
|
||
escapeValue: false
|
||
},
|
||
react: {
|
||
useSuspense: false
|
||
}
|
||
});
|
||
|
||
// Make i18n available globally for components
|
||
global.i18n = i18n;
|
||
|
||
// Import split modules
|
||
const config = require("./prerender/config.cjs");
|
||
|
||
// Import shop config - using require with Babel transpilation
|
||
const shopConfig = require("./src/config.js").default;
|
||
const { renderPage } = require("./prerender/renderer.cjs");
|
||
const {
|
||
collectAllCategories,
|
||
} = require("./prerender/utils.cjs");
|
||
const {
|
||
generateProductMetaTags,
|
||
generateProductJsonLd,
|
||
generateCategoryJsonLd,
|
||
generateHomepageMetaTags,
|
||
generateHomepageJsonLd,
|
||
generateSitemapJsonLd,
|
||
generateKonfiguratorMetaTags,
|
||
generateXmlSitemap,
|
||
generateRobotsTxt,
|
||
generateProductsXml,
|
||
generateLlmsTxt,
|
||
generateCategoryLlmsTxt,
|
||
generateAllCategoryLlmsPages,
|
||
generateCategoryProductList,
|
||
} = require("./prerender/seo.cjs");
|
||
const {
|
||
fetchCategoryProducts,
|
||
fetchProductDetails,
|
||
saveProductImages,
|
||
saveCategoryImages,
|
||
} = require("./prerender/data-fetching.cjs");
|
||
|
||
// Import components
|
||
const PrerenderCategory = require("./src/PrerenderCategory.js").default;
|
||
const PrerenderProduct = require("./src/PrerenderProduct.js").default;
|
||
const PrerenderKonfigurator = require("./src/PrerenderKonfigurator.js").default;
|
||
const PrerenderProfile = require("./src/PrerenderProfile.js").default;
|
||
|
||
// Import static page components
|
||
const Datenschutz = require("./src/pages/Datenschutz.js").default;
|
||
const Impressum = require("./src/pages/Impressum.js").default;
|
||
const Batteriegesetzhinweise =
|
||
require("./src/pages/Batteriegesetzhinweise.js").default;
|
||
const Widerrufsrecht = require("./src/pages/Widerrufsrecht.js").default;
|
||
const Sitemap = require("./src/pages/Sitemap.js").default;
|
||
const PrerenderSitemap = require("./src/PrerenderSitemap.js").default;
|
||
const PrerenderCategoriesPage = require("./src/PrerenderCategoriesPage.js").default;
|
||
const AGB = require("./src/pages/AGB.js").default;
|
||
const NotFound404 = require("./src/pages/NotFound404.js").default;
|
||
|
||
// Worker function for parallel product rendering
|
||
const renderProductWorker = async (productSeoNames, workerId, progressCallback, categoryMap = {}) => {
|
||
const socketUrl = "http://127.0.0.1:9303";
|
||
const workerSocket = io(socketUrl, {
|
||
path: "/socket.io/",
|
||
transports: ["websocket"],
|
||
reconnection: false,
|
||
timeout: 10000,
|
||
});
|
||
|
||
return new Promise((resolve) => {
|
||
let processedCount = 0;
|
||
let successCount = 0;
|
||
const results = [];
|
||
|
||
const processNextProduct = async () => {
|
||
if (processedCount >= productSeoNames.length) {
|
||
workerSocket.disconnect();
|
||
resolve({ successCount, results, workerId });
|
||
return;
|
||
}
|
||
|
||
const productSeoName = productSeoNames[processedCount];
|
||
processedCount++;
|
||
|
||
try {
|
||
const productDetails = await fetchProductDetails(workerSocket, productSeoName);
|
||
|
||
const actualSeoName = productDetails.product.seoName || productSeoName;
|
||
const productComponent = React.createElement(PrerenderProduct, {
|
||
productData: productDetails,
|
||
t: global.i18n.t.bind(global.i18n),
|
||
});
|
||
|
||
const filename = `Artikel/${actualSeoName}`;
|
||
const location = `/Artikel/${actualSeoName}`;
|
||
const description = `Product "${productDetails.product.name}" (seoName: ${productSeoName})`;
|
||
const metaTags = generateProductMetaTags({
|
||
...productDetails.product,
|
||
seoName: actualSeoName,
|
||
}, shopConfig.baseUrl, shopConfig);
|
||
// Get category info from categoryMap if available
|
||
const categoryInfo = productDetails.product.categoryId ? categoryMap[productDetails.product.categoryId] : null;
|
||
|
||
const jsonLdScript = generateProductJsonLd({
|
||
...productDetails.product,
|
||
seoName: actualSeoName,
|
||
}, shopConfig.baseUrl, shopConfig, categoryInfo);
|
||
const combinedMetaTags = metaTags + "\n" + jsonLdScript;
|
||
|
||
const success = renderPage(
|
||
productComponent,
|
||
location,
|
||
filename,
|
||
description,
|
||
combinedMetaTags,
|
||
true,
|
||
config,
|
||
true, // Suppress logs during parallel rendering to avoid interfering with progress bar
|
||
productDetails // Pass product data for cache population
|
||
);
|
||
|
||
if (success) {
|
||
successCount++;
|
||
}
|
||
|
||
const result = {
|
||
productSeoName,
|
||
productName: productDetails.product.name,
|
||
success,
|
||
workerId
|
||
};
|
||
|
||
results.push(result);
|
||
|
||
// Call progress callback if provided
|
||
if (progressCallback) {
|
||
progressCallback(result);
|
||
}
|
||
|
||
// Small delay to avoid overwhelming the server
|
||
setTimeout(processNextProduct, 25);
|
||
} catch (error) {
|
||
const result = {
|
||
productSeoName,
|
||
productName: productSeoName,
|
||
success: false,
|
||
error: error.message,
|
||
workerId
|
||
};
|
||
|
||
results.push(result);
|
||
|
||
// Call progress callback if provided
|
||
if (progressCallback) {
|
||
progressCallback(result);
|
||
}
|
||
|
||
setTimeout(processNextProduct, 25);
|
||
}
|
||
};
|
||
|
||
workerSocket.on("connect", () => {
|
||
processNextProduct();
|
||
});
|
||
|
||
workerSocket.on("connect_error", (err) => {
|
||
console.error(`Worker ${workerId} socket connection error:`, err);
|
||
resolve({ successCount: 0, results: [], workerId });
|
||
});
|
||
});
|
||
};
|
||
|
||
// Function to render products in parallel
|
||
const renderProductsInParallel = async (allProductsArray, maxWorkers, totalProducts, categoryMap = {}) => {
|
||
// Shared progress tracking
|
||
let completedProducts = 0;
|
||
let totalSuccessCount = 0;
|
||
let lastProductName = '';
|
||
const progressResults = [];
|
||
const workerCounts = new Array(maxWorkers).fill(0); // Track per-worker progress
|
||
const workerSuccess = new Array(maxWorkers).fill(0); // Track per-worker success count
|
||
|
||
// Helper function to display progress bar with worker stats
|
||
const updateProgressBar = (current, total, productName = '') => {
|
||
const percentage = Math.round((current / total) * 100);
|
||
const barLength = 30;
|
||
const filledLength = Math.round((barLength * current) / total);
|
||
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
|
||
|
||
// @note Single line progress update to prevent flickering
|
||
const truncatedName = productName ? ` - ${productName.substring(0, 25)}${productName.length > 25 ? '...' : ''}` : '';
|
||
|
||
// Build worker stats on one line
|
||
let workerStats = '';
|
||
for (let i = 0; i < Math.min(maxWorkers, 8); i++) { // Limit to 8 workers to fit on screen
|
||
workerStats += `W${i + 1}:${workerCounts[i]}/${workerSuccess[i]} `;
|
||
}
|
||
|
||
// Single line update without complex cursor movements
|
||
process.stdout.write(`\r [${bar}] ${percentage}% (${current}/${total})${truncatedName}\n ${workerStats}${current < total ? '\x1b[1A' : '\n'}`);
|
||
};
|
||
|
||
// Split products among workers
|
||
const productsPerWorker = Math.ceil(allProductsArray.length / maxWorkers);
|
||
const workerPromises = [];
|
||
|
||
// Initial progress bar
|
||
updateProgressBar(0, totalProducts);
|
||
|
||
for (let i = 0; i < maxWorkers; i++) {
|
||
const start = i * productsPerWorker;
|
||
const end = Math.min(start + productsPerWorker, allProductsArray.length);
|
||
const productsForWorker = allProductsArray.slice(start, end);
|
||
|
||
if (productsForWorker.length > 0) {
|
||
const promise = renderProductWorker(productsForWorker, i + 1, (result) => {
|
||
// Progress callback - called each time a product is completed
|
||
completedProducts++;
|
||
progressResults.push(result);
|
||
lastProductName = result.productName;
|
||
|
||
// Update per-worker counters
|
||
const workerIndex = result.workerId - 1; // Convert to 0-based index
|
||
workerCounts[workerIndex]++;
|
||
|
||
if (result.success) {
|
||
totalSuccessCount++;
|
||
workerSuccess[workerIndex]++;
|
||
} else if (result.error) {
|
||
// Don't log errors immediately to avoid interfering with progress bar
|
||
// Errors will be shown after completion
|
||
}
|
||
|
||
// Update progress bar with worker stats
|
||
updateProgressBar(completedProducts, totalProducts, lastProductName);
|
||
}, categoryMap);
|
||
|
||
workerPromises.push(promise);
|
||
}
|
||
}
|
||
|
||
try {
|
||
// Wait for all workers to complete
|
||
await Promise.all(workerPromises);
|
||
|
||
// Ensure final progress update
|
||
updateProgressBar(totalProducts, totalProducts, lastProductName);
|
||
|
||
// Show any errors that occurred
|
||
const errorResults = progressResults.filter(r => !r.success && r.error);
|
||
if (errorResults.length > 0) {
|
||
console.log(`\n❌ ${errorResults.length} products failed to render:`);
|
||
errorResults.forEach(result => {
|
||
console.log(` - ${result.productSeoName}: ${result.error}`);
|
||
});
|
||
}
|
||
|
||
return totalSuccessCount;
|
||
} catch (error) {
|
||
console.error('Error in parallel rendering:', error);
|
||
return totalSuccessCount; // Return what we managed to complete
|
||
}
|
||
};
|
||
|
||
const renderApp = async (categoryData, socket) => {
|
||
if (categoryData) {
|
||
global.window.categoryCache = {
|
||
"209_de": categoryData,
|
||
};
|
||
// @note Make cache available to components during rendering
|
||
global.categoryCache = global.window.categoryCache;
|
||
} else {
|
||
global.window.categoryCache = {};
|
||
global.categoryCache = {};
|
||
}
|
||
|
||
// Helper to call renderPage with config
|
||
const render = (
|
||
component,
|
||
location,
|
||
filename,
|
||
description,
|
||
metaTags = "",
|
||
needsRouter = false
|
||
) => {
|
||
return renderPage(
|
||
component,
|
||
location,
|
||
filename,
|
||
description,
|
||
metaTags,
|
||
needsRouter,
|
||
config
|
||
);
|
||
};
|
||
|
||
console.log("🏠 Rendering home page...");
|
||
const PrerenderHome = require("./src/PrerenderHome.js").default;
|
||
const homeComponent = React.createElement(PrerenderHome, null);
|
||
const homeFilename = config.isProduction
|
||
? "index.html"
|
||
: "index.prerender.html";
|
||
const homeMetaTags = generateHomepageMetaTags(shopConfig.baseUrl, shopConfig);
|
||
const homepageCategories = categoryData ? collectAllCategories(categoryData) : [];
|
||
const homeJsonLd = generateHomepageJsonLd(shopConfig.baseUrl, shopConfig, homepageCategories);
|
||
const combinedHomeMeta = homeMetaTags + "\n" + homeJsonLd;
|
||
const homeSuccess = render(
|
||
homeComponent,
|
||
"/",
|
||
homeFilename,
|
||
"Home page",
|
||
combinedHomeMeta,
|
||
true
|
||
);
|
||
|
||
if (!homeSuccess) {
|
||
process.exit(1);
|
||
}
|
||
|
||
// Copy index.html to resetPassword (no file extension) for SPA routing
|
||
if (config.isProduction) {
|
||
const indexPath = path.resolve(__dirname, config.outputDir, "index.html");
|
||
const resetPasswordPath = path.resolve(__dirname, config.outputDir, "resetPassword");
|
||
fs.copyFileSync(indexPath, resetPasswordPath);
|
||
console.log(`✅ Copied index.html to ${resetPasswordPath}`);
|
||
}
|
||
|
||
// Render static pages
|
||
console.log("\n📄 Rendering static pages...");
|
||
|
||
const staticPages = [
|
||
{
|
||
component: Datenschutz,
|
||
path: "/datenschutz",
|
||
filename: "datenschutz",
|
||
description: "Datenschutz page",
|
||
},
|
||
{
|
||
component: Impressum,
|
||
path: "/impressum",
|
||
filename: "impressum",
|
||
description: "Impressum page",
|
||
},
|
||
{
|
||
component: Batteriegesetzhinweise,
|
||
path: "/batteriegesetzhinweise",
|
||
filename: "batteriegesetzhinweise",
|
||
description: "Batteriegesetzhinweise page",
|
||
},
|
||
{
|
||
component: Widerrufsrecht,
|
||
path: "/widerrufsrecht",
|
||
filename: "widerrufsrecht",
|
||
description: "Widerrufsrecht page",
|
||
},
|
||
{
|
||
component: PrerenderSitemap,
|
||
path: "/sitemap",
|
||
filename: "sitemap",
|
||
description: "Sitemap page",
|
||
needsCategoryData: true,
|
||
},
|
||
{
|
||
component: PrerenderCategoriesPage,
|
||
path: "/Kategorien",
|
||
filename: "Kategorien",
|
||
description: "Categories page",
|
||
needsCategoryData: true,
|
||
},
|
||
{ component: AGB, path: "/agb", filename: "agb", description: "AGB page" },
|
||
{ component: NotFound404, path: "/404", filename: "404", description: "404 Not Found page" },
|
||
{
|
||
component: PrerenderKonfigurator,
|
||
path: "/Konfigurator",
|
||
filename: "Konfigurator",
|
||
description: "Growbox Konfigurator page",
|
||
},
|
||
{
|
||
component: PrerenderProfile,
|
||
path: "/profile",
|
||
filename: "profile",
|
||
description: "Profile page",
|
||
},
|
||
];
|
||
|
||
let staticPagesRendered = 0;
|
||
for (const page of staticPages) {
|
||
// Pass category data as props if needed
|
||
const pageProps = page.needsCategoryData ? { categoryData } : null;
|
||
const pageComponent = React.createElement(page.component, pageProps);
|
||
let metaTags = "";
|
||
|
||
// Special handling for Sitemap page to include category data
|
||
if (page.filename === "sitemap" && categoryData) {
|
||
const sitemapCategories = collectAllCategories(categoryData);
|
||
metaTags = generateSitemapJsonLd(sitemapCategories, shopConfig.baseUrl, shopConfig);
|
||
}
|
||
|
||
// Special handling for Konfigurator page to include SEO tags
|
||
if (page.filename === "Konfigurator") {
|
||
const konfiguratorMetaTags = generateKonfiguratorMetaTags(shopConfig.baseUrl, shopConfig);
|
||
metaTags = konfiguratorMetaTags;
|
||
}
|
||
|
||
const success = render(
|
||
pageComponent,
|
||
page.path,
|
||
page.filename,
|
||
page.description,
|
||
metaTags,
|
||
true
|
||
);
|
||
if (success) {
|
||
staticPagesRendered++;
|
||
}
|
||
}
|
||
|
||
console.log(
|
||
`✅ Successfully rendered ${staticPagesRendered}/${staticPages.length} static pages!`
|
||
);
|
||
|
||
// Collect all products for product page generation
|
||
const allProducts = new Set();
|
||
const allProductsData = []; // @note Store full product data for products.xml generation
|
||
|
||
// Generate category pages if we have category data
|
||
if (categoryData && socket) {
|
||
console.log("\n📂 Rendering category pages with product data...");
|
||
const allCategories = collectAllCategories(categoryData);
|
||
console.log(`Found ${allCategories.length} categories to render`);
|
||
|
||
// First, collect category images for all categories
|
||
console.log("\n📂 Collecting category images...");
|
||
await saveCategoryImages(socket, allCategories, config.outputDir);
|
||
|
||
let categoryPagesRendered = 0;
|
||
let categoriesWithProducts = 0;
|
||
|
||
const allCategoriesPlusNeu = [...allCategories, {
|
||
id: "neu",
|
||
name: "Neuheiten",
|
||
seoName: "neu",
|
||
parentId: 209
|
||
}];
|
||
|
||
for (const category of allCategoriesPlusNeu) {
|
||
// Skip categories without seoName
|
||
if (!category.seoName) {
|
||
console.log(
|
||
`⚠️ Skipping category "${category.name}" (ID: ${category.id}) - no seoName`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
console.log(
|
||
`\n🔍 Fetching products for category "${category.name}" (ID: ${category.id})...`
|
||
);
|
||
|
||
let productData = null;
|
||
try {
|
||
productData = await fetchCategoryProducts(socket, category.id);
|
||
console.log(
|
||
` ✅ Found ${productData.products ? productData.products.length : 0
|
||
} products`
|
||
);
|
||
|
||
if (productData.products && productData.products.length > 0) {
|
||
categoriesWithProducts++;
|
||
|
||
// Collect products for individual page generation
|
||
productData.products.forEach((product) => {
|
||
if (product.seoName) {
|
||
allProducts.add(product.seoName);
|
||
// @note Store full product data for products.xml generation with category ID
|
||
allProductsData.push({
|
||
...product,
|
||
seoName: product.seoName,
|
||
categoryId: category.id // Add the category ID for Google Shopping category mapping
|
||
});
|
||
}
|
||
});
|
||
|
||
// Fetch and save product images
|
||
await saveProductImages(
|
||
socket,
|
||
productData.products,
|
||
category.name,
|
||
config.outputDir
|
||
);
|
||
|
||
// Don't accumulate data in global cache - just use the data directly for this page
|
||
// The global cache should only contain the static category tree
|
||
}
|
||
} catch (productError) {
|
||
console.log(` ⚠️ No products found: ${productError.message}`);
|
||
}
|
||
|
||
const categoryComponent = React.createElement(PrerenderCategory, {
|
||
categoryId: category.id,
|
||
categoryName: category.name,
|
||
categorySeoName: category.seoName,
|
||
productData: productData,
|
||
});
|
||
|
||
const filename = `Kategorie/${category.seoName}`;
|
||
const location = `/Kategorie/${category.seoName}`;
|
||
const description = `Category "${category.name}" (ID: ${category.id})`;
|
||
const categoryJsonLd = generateCategoryJsonLd(
|
||
category,
|
||
productData?.products || [],
|
||
shopConfig.baseUrl,
|
||
shopConfig
|
||
);
|
||
|
||
const success = render(
|
||
categoryComponent,
|
||
location,
|
||
filename,
|
||
description,
|
||
categoryJsonLd,
|
||
true
|
||
);
|
||
if (success) {
|
||
categoryPagesRendered++;
|
||
}
|
||
|
||
// Small delay to avoid overwhelming the server
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
} catch (error) {
|
||
console.error(
|
||
`❌ Failed to render category ${category.id} (${category.name}):`,
|
||
error
|
||
);
|
||
}
|
||
}
|
||
|
||
console.log(
|
||
`\n🎉 Successfully rendered ${categoryPagesRendered} category pages!`
|
||
);
|
||
console.log(`📦 ${categoriesWithProducts} categories had product data`);
|
||
|
||
// Generate individual product pages
|
||
if (allProducts.size > 0) {
|
||
const totalProducts = allProducts.size;
|
||
const numCPUs = os.cpus().length;
|
||
const maxWorkers = Math.min(numCPUs, totalProducts, 8); // Cap at 8 workers to avoid overwhelming the server
|
||
|
||
// Create category map for breadcrumbs
|
||
const categoryMap = {};
|
||
allCategories.forEach(category => {
|
||
categoryMap[category.id] = {
|
||
name: category.name,
|
||
seoName: category.seoName
|
||
};
|
||
});
|
||
|
||
console.log(
|
||
`\n📦 Rendering ${totalProducts} individual product pages using ${maxWorkers} parallel workers...`
|
||
);
|
||
|
||
const productPagesRendered = await renderProductsInParallel(
|
||
Array.from(allProducts),
|
||
maxWorkers,
|
||
totalProducts,
|
||
categoryMap
|
||
);
|
||
|
||
console.log(
|
||
`🎉 Successfully rendered ${productPagesRendered}/${totalProducts} product pages!`
|
||
);
|
||
}
|
||
} else {
|
||
console.log(
|
||
"⚠️ No category data or socket available - skipping category page generation"
|
||
);
|
||
}
|
||
|
||
// No longer writing combined CSS file - each page has its own embedded CSS
|
||
|
||
// Generate XML sitemap with all rendered pages
|
||
console.log("\n🗺️ Generating XML sitemap...");
|
||
const allCategories = categoryData ? collectAllCategories(categoryData) : [];
|
||
const allProductsArray = Array.from(allProducts);
|
||
const xmlSitemap = generateXmlSitemap(allCategories, allProductsArray, shopConfig.baseUrl);
|
||
|
||
const sitemapPath = path.resolve(__dirname, config.outputDir, "sitemap.xml");
|
||
fs.writeFileSync(sitemapPath, xmlSitemap);
|
||
|
||
console.log(`✅ XML sitemap generated: ${sitemapPath}`);
|
||
console.log(` - Homepage: 1 URL`);
|
||
console.log(` - Static pages: 6 URLs`);
|
||
console.log(` - Category pages: ${allCategories.length} URLs`);
|
||
console.log(` - Product pages: ${allProductsArray.length} URLs`);
|
||
console.log(
|
||
` - Total URLs: ${1 + 6 + allCategories.length + allProductsArray.length}`
|
||
);
|
||
|
||
// Generate robots.txt
|
||
console.log("\n🤖 Generating robots.txt...");
|
||
const robotsTxtContent = generateRobotsTxt(shopConfig.baseUrl);
|
||
const robotsTxtPath = path.resolve(__dirname, config.outputDir, "robots.txt");
|
||
fs.writeFileSync(robotsTxtPath, robotsTxtContent);
|
||
|
||
console.log(`✅ robots.txt generated: ${robotsTxtPath}`);
|
||
console.log(` - Allows all crawlers`);
|
||
console.log(` - References sitemap.xml`);
|
||
console.log(` - Includes crawl-delay directive`);
|
||
|
||
// Generate products.xml (Google Shopping feed) in parallel to sitemap.xml
|
||
if (allProductsData.length > 0) {
|
||
console.log("\n🛒 Generating products.xml (Google Shopping feed)...");
|
||
|
||
try {
|
||
const productsXml = generateProductsXml(allProductsData, shopConfig.baseUrl, shopConfig);
|
||
|
||
const productsXmlPath = path.resolve(__dirname, config.outputDir, "products.xml");
|
||
|
||
// Write with explicit UTF-8 encoding
|
||
fs.writeFileSync(productsXmlPath, productsXml, { encoding: 'utf8' });
|
||
|
||
console.log(`✅ products.xml generated: ${productsXmlPath}`);
|
||
console.log(` - Products included: ${allProductsData.length}`);
|
||
console.log(` - Format: Google Shopping RSS 2.0 feed`);
|
||
console.log(` - Encoding: UTF-8`);
|
||
console.log(` - Includes: title, description, price, availability, images`);
|
||
|
||
// Verify the file is valid UTF-8
|
||
try {
|
||
const verification = fs.readFileSync(productsXmlPath, 'utf8');
|
||
console.log(` - File verification: ✅ Valid UTF-8 (${Math.round(verification.length / 1024)}KB)`);
|
||
} catch (verifyError) {
|
||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||
}
|
||
|
||
// Validate XML against Google Shopping schema
|
||
try {
|
||
const ProductsXmlValidator = require('./scripts/validate-products-xml.cjs');
|
||
const validator = new ProductsXmlValidator(productsXmlPath);
|
||
const validationResults = await validator.validate();
|
||
|
||
if (validationResults.valid) {
|
||
console.log(` - Schema validation: ✅ Valid Google Shopping RSS 2.0`);
|
||
} else {
|
||
console.log(` - Schema validation: ⚠️ ${validationResults.summary.errorCount} errors, ${validationResults.summary.warningCount} warnings`);
|
||
|
||
// Show first few errors for quick debugging
|
||
if (validationResults.errors.length > 0) {
|
||
console.log(` - First error: ${validationResults.errors[0].message}`);
|
||
}
|
||
}
|
||
} catch (validationError) {
|
||
console.log(` - Schema validation: ⚠️ Validation failed: ${validationError.message}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error generating products.xml: ${error.message}`);
|
||
console.log("\n⚠️ Skipping products.xml generation due to errors");
|
||
}
|
||
} else {
|
||
console.log("\n⚠️ No product data available - skipping products.xml generation");
|
||
}
|
||
|
||
// Generate llms.txt (LLM-friendly markdown sitemap) and category-specific files
|
||
console.log("\n🤖 Generating LLM sitemap files...");
|
||
|
||
try {
|
||
// Generate main llms.txt overview file
|
||
const llmsTxt = generateLlmsTxt(allCategories, allProductsData, shopConfig.baseUrl, shopConfig);
|
||
const llmsTxtPath = path.resolve(__dirname, config.outputDir, "llms.txt");
|
||
fs.writeFileSync(llmsTxtPath, llmsTxt, { encoding: 'utf8' });
|
||
|
||
console.log(`✅ Main llms.txt generated: ${llmsTxtPath}`);
|
||
console.log(` - Static pages: 8 pages`);
|
||
console.log(` - Categories: ${allCategories.length} with links to detailed files`);
|
||
console.log(` - File size: ${Math.round(llmsTxt.length / 1024)}KB`);
|
||
|
||
// Group products by category for category-specific files
|
||
const productsByCategory = {};
|
||
allProductsData.forEach((product) => {
|
||
const categoryId = product.categoryId || 'uncategorized';
|
||
if (!productsByCategory[categoryId]) {
|
||
productsByCategory[categoryId] = [];
|
||
}
|
||
productsByCategory[categoryId].push(product);
|
||
});
|
||
|
||
// Generate category-specific LLM files with pagination
|
||
let categoryFilesGenerated = 0;
|
||
let totalCategoryProducts = 0;
|
||
let totalPaginatedFiles = 0;
|
||
|
||
for (const category of allCategories) {
|
||
if (category.seoName) {
|
||
const categoryProducts = productsByCategory[category.id] || [];
|
||
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||
|
||
// Generate all paginated files for this category
|
||
const categoryPages = generateAllCategoryLlmsPages(category, categoryProducts, shopConfig.baseUrl, shopConfig);
|
||
|
||
// Write each paginated file
|
||
for (const page of categoryPages) {
|
||
const pagePath = path.resolve(__dirname, config.outputDir, page.fileName);
|
||
fs.writeFileSync(pagePath, page.content, { encoding: 'utf8' });
|
||
totalPaginatedFiles++;
|
||
}
|
||
|
||
// Generate and write the product list file for this category
|
||
const productList = generateCategoryProductList(category, categoryProducts);
|
||
const listPath = path.resolve(__dirname, config.outputDir, productList.fileName);
|
||
fs.writeFileSync(listPath, productList.content, { encoding: 'utf8' });
|
||
|
||
const pageCount = categoryPages.length;
|
||
const totalSize = categoryPages.reduce((sum, page) => sum + page.content.length, 0);
|
||
|
||
console.log(` ✅ llms-${categorySlug}-page-*.txt - ${categoryProducts.length} products across ${pageCount} pages (${Math.round(totalSize / 1024)}KB total)`);
|
||
console.log(` 📋 ${productList.fileName} - ${productList.productCount} products (${Math.round(productList.content.length / 1024)}KB)`);
|
||
|
||
categoryFilesGenerated++;
|
||
totalCategoryProducts += categoryProducts.length;
|
||
}
|
||
}
|
||
|
||
console.log(` 📄 Total paginated files generated: ${totalPaginatedFiles}`);
|
||
console.log(` 📦 Total products across all categories: ${totalCategoryProducts}`);
|
||
|
||
try {
|
||
const verification = fs.readFileSync(llmsTxtPath, 'utf8');
|
||
console.log(` - File verification: ✅ All files valid UTF-8`);
|
||
} catch (verifyError) {
|
||
console.log(` - File verification: ⚠️ ${verifyError.message}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error generating LLM sitemap files: ${error.message}`);
|
||
console.log("\n⚠️ Skipping LLM sitemap generation due to errors");
|
||
}
|
||
};
|
||
|
||
const fetchCategoryDataAndRender = () => {
|
||
const socketUrl = "http://127.0.0.1:9303";
|
||
console.log(`Connecting to socket at ${socketUrl} to fetch categories...`);
|
||
|
||
const timeout = setTimeout(() => {
|
||
console.error(
|
||
"Error: Prerender script timed out after 15 seconds. Check backend connectivity."
|
||
);
|
||
process.exit(1);
|
||
}, 15000);
|
||
|
||
const socket = io(socketUrl, {
|
||
path: "/socket.io/",
|
||
transports: ["websocket"],
|
||
reconnection: false,
|
||
timeout: 10000,
|
||
});
|
||
|
||
socket.on("connect", () => {
|
||
console.log('Socket connected. Emitting "categoryList"...');
|
||
socket.emit("categoryList", { categoryId: 209 }, async (response) => {
|
||
clearTimeout(timeout);
|
||
|
||
if (response && response.categoryTree) {
|
||
console.log("Successfully fetched category data.");
|
||
await renderApp(response.categoryTree, socket);
|
||
} else {
|
||
console.error("Error: Invalid category data received.", response);
|
||
await renderApp(null, socket);
|
||
}
|
||
socket.disconnect();
|
||
});
|
||
});
|
||
|
||
socket.on("connect_error", async (err) => {
|
||
clearTimeout(timeout);
|
||
console.error("Socket connection error:", err);
|
||
await renderApp(null, null);
|
||
socket.disconnect();
|
||
});
|
||
|
||
socket.on("error", async (err) => {
|
||
clearTimeout(timeout);
|
||
console.error("Socket error:", err);
|
||
await renderApp(null, null);
|
||
socket.disconnect();
|
||
});
|
||
|
||
socket.on("disconnect", (reason) => {
|
||
console.log(`Socket disconnected: ${reason}`);
|
||
clearTimeout(timeout);
|
||
});
|
||
};
|
||
|
||
fetchCategoryDataAndRender();
|