This commit is contained in:
seb
2025-07-02 12:49:06 +02:00
commit edbd56f6a9
123 changed files with 32598 additions and 0 deletions

71
prerender/config.cjs Normal file
View File

@@ -0,0 +1,71 @@
const fs = require('fs');
const path = require('path');
// Determine if we're in production mode
const isProduction = process.env.NODE_ENV === 'production';
const outputDir = isProduction ? 'dist' : 'public';
console.log(`🔧 Prerender mode: ${isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`);
console.log(`📁 Output directory: ${outputDir}`);
// Function to get webpack entrypoints for production
const getWebpackEntrypoints = () => {
if (!isProduction) return { js: [], css: [] };
const distPath = path.resolve(__dirname, '..', 'dist');
const entrypoints = { js: [], css: [] };
try {
// Look for the main HTML file to extract script and link tags
const htmlPath = path.join(distPath, 'index.html');
if (fs.existsSync(htmlPath)) {
const htmlContent = fs.readFileSync(htmlPath, 'utf8');
// Extract script tags
const scriptMatches = htmlContent.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/g) || [];
scriptMatches.forEach(match => {
const srcMatch = match.match(/src="([^"]*)"/);
if (srcMatch) {
entrypoints.js.push(srcMatch[1]);
}
});
// Extract CSS link tags
const linkMatches = htmlContent.match(/<link[^>]*href="([^"]*\.css)"[^>]*>/g) || [];
linkMatches.forEach(match => {
const hrefMatch = match.match(/href="([^"]*)"/);
if (hrefMatch) {
entrypoints.css.push(hrefMatch[1]);
}
});
console.log(`📦 Found webpack entrypoints:`);
console.log(` JS files: ${entrypoints.js.length} files`);
console.log(` CSS files: ${entrypoints.css.length} files`);
}
} catch (error) {
console.warn(`⚠️ Could not read webpack entrypoints: ${error.message}`);
}
return entrypoints;
};
// Read global CSS styles and fix font paths for prerender
let globalCss = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'index.css'), 'utf8');
// Fix relative font paths for prerendered HTML (remove ../public to make them relative to public root)
globalCss = globalCss.replace(/url\('\.\.\/public/g, "url('");
// Global CSS collection
const globalCssCollection = new Set();
// Get webpack entrypoints
const webpackEntrypoints = getWebpackEntrypoints();
module.exports = {
isProduction,
outputDir,
getWebpackEntrypoints,
globalCss,
globalCssCollection,
webpackEntrypoints
};

354
prerender/data-fetching.cjs Normal file
View File

@@ -0,0 +1,354 @@
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const fetchCategoryTree = (socket, categoryId) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(
new Error(`Timeout fetching category tree for category ${categoryId}`)
);
}, 5000);
socket.emit(
"categoryList",
{ categoryId: parseInt(categoryId) },
(response) => {
clearTimeout(timeout);
if (response && response.categoryTree) {
resolve(response);
} else {
reject(
new Error(
`Invalid category tree response for category ${categoryId}: ${JSON.stringify(
response
)}`
)
);
}
}
);
});
};
const fetchCategoryProducts = (socket, categoryId) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timeout fetching products for category ${categoryId}`));
}, 5000);
socket.emit(
"getCategoryProducts",
{ categoryId: parseInt(categoryId) },
(response) => {
clearTimeout(timeout);
if (response && response.products !== undefined) {
resolve(response);
} else {
reject(
new Error(
`Invalid response for category ${categoryId}: ${JSON.stringify(
response
)}`
)
);
}
}
);
});
};
const fetchProductDetails = (socket, productSeoName) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(
new Error(
`Timeout fetching product details for product ${productSeoName}`
)
);
}, 5000);
socket.emit("getProductView", { seoName: productSeoName, nocount: true }, (response) => {
clearTimeout(timeout);
if (response && response.product) {
response.product.seoName = productSeoName;
resolve(response);
} else {
reject(
new Error(
`Invalid product response for product ${productSeoName}: ${JSON.stringify(
response
)}`
)
);
}
});
});
};
const fetchProductImage = (socket, bildId) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timeout fetching image ${bildId}`));
}, 10000);
socket.emit("getPic", { bildId, size: "medium" }, (res) => {
clearTimeout(timeout);
if (res.success && res.imageBuffer) {
resolve(res.imageBuffer);
} else {
reject(
new Error(`Failed to fetch image ${bildId}: ${JSON.stringify(res)}`)
);
}
});
});
};
const fetchCategoryImage = (socket, categoryId) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timeout fetching category image for category ${categoryId}`));
}, 10000);
socket.emit("getCategoryPic", { categoryId }, (response) => {
clearTimeout(timeout);
if (response.success && response.image) {
resolve(response.image);
} else {
reject(
new Error(`Failed to fetch category image for ${categoryId}: ${JSON.stringify(response)}`)
);
}
});
});
};
const saveProductImages = async (socket, products, categoryName, outputDir) => {
if (!products || products.length === 0) return;
const assetsPath = path.resolve(
__dirname,
"..",
outputDir,
"assets",
"images"
);
const overlayPath = path.resolve(
__dirname,
"..",
"public",
"assets",
"images",
"sh.png"
);
// Ensure assets/images directory exists
if (!fs.existsSync(assetsPath)) {
fs.mkdirSync(assetsPath, { recursive: true });
}
// Check if overlay file exists
if (!fs.existsSync(overlayPath)) {
console.log(
` ⚠️ Overlay file not found at ${overlayPath} - images will be saved without overlay`
);
}
let imagesSaved = 0;
let imagesSkipped = 0;
console.log(
` 📷 Fetching images for ${products.length} products in "${categoryName}"...`
);
for (const product of products) {
if (product.pictureList && product.pictureList.trim()) {
// Parse pictureList string to get image IDs
const imageIds = product.pictureList
.split(",")
.map((id) => id.trim())
.filter((id) => id);
if (imageIds.length > 0) {
// Process first image for each product
const bildId = parseInt(imageIds[0]);
const estimatedFilename = `prod${bildId}.jpg`; // We'll generate a filename based on the ID
const imagePath = path.join(assetsPath, estimatedFilename);
// Skip if image already exists
if (fs.existsSync(imagePath)) {
imagesSkipped++;
continue;
}
try {
const imageBuffer = await fetchProductImage(socket, bildId);
// If overlay exists, apply it to the image
if (fs.existsSync(overlayPath)) {
try {
// Get image dimensions to center the overlay
const baseImage = sharp(Buffer.from(imageBuffer));
const baseMetadata = await baseImage.metadata();
const overlaySize = Math.min(baseMetadata.width, baseMetadata.height) * 0.4;
// Resize overlay to 20% of base image size and get its buffer
const resizedOverlayBuffer = await sharp(overlayPath)
.resize({
width: Math.round(overlaySize),
height: Math.round(overlaySize),
fit: 'contain', // Keep full overlay visible
background: { r: 0, g: 0, b: 0, alpha: 0 } // Transparent background instead of black bars
})
.toBuffer();
// Calculate center position for the resized overlay
const centerX = Math.floor((baseMetadata.width - overlaySize) / 2);
const centerY = Math.floor((baseMetadata.height - overlaySize) / 2);
const processedImageBuffer = await baseImage
.composite([
{
input: resizedOverlayBuffer,
top: centerY,
left: centerX,
blend: "multiply", // Darkens the image, visible on all backgrounds
opacity: 0.3,
},
])
.jpeg() // Ensure output is JPEG
.toBuffer();
fs.writeFileSync(imagePath, processedImageBuffer);
console.log(
` ✅ Applied centered inverted sh.png overlay to ${estimatedFilename}`
);
} catch (overlayError) {
console.log(
` ⚠️ Failed to apply overlay to ${estimatedFilename}: ${overlayError.message}`
);
// Fallback: save without overlay
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
}
} else {
// Save without overlay if overlay file doesn't exist
fs.writeFileSync(imagePath, Buffer.from(imageBuffer));
}
imagesSaved++;
// Small delay to avoid overwhelming server
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (error) {
console.log(
` ⚠️ Failed to fetch image ${estimatedFilename} (ID: ${bildId}): ${error.message}`
);
}
}
}
}
if (imagesSaved > 0 || imagesSkipped > 0) {
console.log(
` 📷 Images: ${imagesSaved} saved, ${imagesSkipped} already exist`
);
}
};
const saveCategoryImages = async (socket, categories, outputDir) => {
if (!categories || categories.length === 0) {
console.log(" ⚠️ No categories provided for image collection");
return;
}
console.log(` 📂 Attempting to fetch images for ${categories.length} categories via socket calls...`);
// Debug: Log categories that will be processed
console.log(" 🔍 Categories to process:");
categories.forEach((cat, index) => {
console.log(` ${index + 1}. "${cat.name}" (ID: ${cat.id}) -> cat${cat.id}.jpg`);
});
const assetsPath = path.resolve(
__dirname,
"..",
outputDir,
"assets",
"images"
);
// Ensure assets/images directory exists
if (!fs.existsSync(assetsPath)) {
fs.mkdirSync(assetsPath, { recursive: true });
}
let imagesSaved = 0;
let imagesSkipped = 0;
let categoriesProcessed = 0;
console.log(
` 📂 Processing categories for image collection...`
);
for (const category of categories) {
categoriesProcessed++;
const estimatedFilename = `cat${category.id}.jpg`; // Use 'cat' prefix with category ID
const imagePath = path.join(assetsPath, estimatedFilename);
// Skip if image already exists
if (fs.existsSync(imagePath)) {
imagesSkipped++;
console.log(` ⏭️ Category image already exists: ${estimatedFilename} (${category.name})`);
continue;
}
try {
console.log(` 🔍 Fetching image for category "${category.name}" (ID: ${category.id})...`);
const imageBuffer = await fetchCategoryImage(socket, category.id);
// Convert to Uint8Array if needed (similar to CategoryBox.js)
const uint8Array = new Uint8Array(imageBuffer);
// Save category images without overlay processing
fs.writeFileSync(imagePath, Buffer.from(uint8Array));
console.log(
` 💾 Saved category image: ${estimatedFilename} (${category.name})`
);
imagesSaved++;
// Small delay to avoid overwhelming server
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (error) {
console.log(
` ⚠️ Failed to fetch category image for "${category.name}" (ID: ${category.id}): ${error.message}`
);
// Continue processing other categories even if one fails
}
}
console.log(
` 📂 Category image collection complete: ${imagesSaved} saved, ${imagesSkipped} already exist`
);
console.log(
` 📊 Summary: ${categoriesProcessed}/${categories.length} categories processed`
);
if (imagesSaved === 0 && imagesSkipped === 0) {
console.log(" ⚠️ No category images were found via socket calls - categories may not have images available");
}
};
module.exports = {
fetchCategoryTree,
fetchCategoryProducts,
fetchProductDetails,
fetchProductImage,
fetchCategoryImage,
saveProductImages,
saveCategoryImages,
};

254
prerender/renderer.cjs Normal file
View File

@@ -0,0 +1,254 @@
const fs = require("fs");
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const { StaticRouter } = require("react-router");
const { CacheProvider } = require("@emotion/react");
const { ThemeProvider } = require("@mui/material/styles");
const createEmotionCache = require("../createEmotionCache.js").default;
const theme = require("../src/theme.js").default;
const createEmotionServer = require("@emotion/server/create-instance").default;
const renderPage = (
component,
location,
filename,
description,
metaTags = "",
needsRouter = false,
config,
suppressLogs = false
) => {
const {
isProduction,
outputDir,
globalCss,
globalCssCollection,
webpackEntrypoints,
} = config;
const { writeCombinedCssFile, optimizeCss } = require("./utils.cjs");
// @note Set prerender fallback flag in global environment for CategoryBox during SSR
if (typeof global !== "undefined" && global.window) {
global.window.__PRERENDER_FALLBACK__ = {
path: location,
timestamp: Date.now()
};
}
// Create fresh Emotion cache for each page
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
const wrappedComponent = needsRouter
? React.createElement(StaticRouter, { location: location }, component)
: component;
const pageElement = React.createElement(
CacheProvider,
{ value: cache },
React.createElement(ThemeProvider, { theme: theme }, wrappedComponent)
);
let renderedMarkup;
try {
renderedMarkup = ReactDOMServer.renderToString(pageElement);
const emotionChunks = extractCriticalToChunks(renderedMarkup);
// Collect CSS from this page
if (emotionChunks.styles.length > 0) {
const oldSize = globalCssCollection.size;
emotionChunks.styles.forEach((style) => {
if (style.css) {
globalCssCollection.add(style.css);
}
});
// Check if new styles were added
if (globalCssCollection.size > oldSize) {
// Write CSS file immediately when new styles are added
writeCombinedCssFile(globalCssCollection, outputDir);
}
}
} catch (error) {
console.error(`❌ Rendering failed for ${filename}:`, error);
return false;
}
// Use appropriate template path based on mode
// In production, use a clean template file, not the already-rendered index.html
const templatePath = isProduction
? path.resolve(__dirname, "..", "dist", "index_template.html")
: path.resolve(__dirname, "..", "public", "index.html");
let template = fs.readFileSync(templatePath, "utf8");
// Build CSS and JS tags with optimized CSS loading
let additionalTags = "";
let inlinedCss = "";
if (isProduction) {
// Check if scripts are already present in template to avoid duplication
const existingScripts =
template.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/g) || [];
const existingScriptSrcs = existingScripts
.map((script) => {
const match = script.match(/src="([^"]*)"/);
return match ? match[1] : null;
})
.filter(Boolean);
// OPTIMIZATION: Inline critical CSS instead of loading externally
// Read and inline webpack CSS files to eliminate render-blocking requests
webpackEntrypoints.css.forEach((cssFile) => {
if (!template.includes(`href="${cssFile}"`)) {
try {
const cssPath = path.resolve(__dirname, "..", "dist", cssFile.replace(/^\//, ""));
if (fs.existsSync(cssPath)) {
const cssContent = fs.readFileSync(cssPath, "utf8");
// Use advanced CSS optimization
const optimizedCss = optimizeCss(cssContent);
inlinedCss += optimizedCss;
if (!suppressLogs) console.log(` ✅ Inlined CSS: ${cssFile} (${Math.round(optimizedCss.length / 1024)}KB)`);
} else {
// Fallback to external loading if file not found
additionalTags += `<link rel="preload" href="${cssFile}" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="${cssFile}"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ CSS file not found for inlining: ${cssPath}, using async loading`);
}
} catch (error) {
// Fallback to external loading if reading fails
additionalTags += `<link rel="preload" href="${cssFile}" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="${cssFile}"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ Error reading CSS file ${cssFile}: ${error.message}, using async loading`);
}
}
});
// Read and inline prerender CSS to eliminate render-blocking request
try {
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
if (fs.existsSync(prerenderCssPath)) {
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
// Use advanced CSS optimization
const optimizedPrerenderCss = optimizeCss(prerenderCssContent);
inlinedCss += optimizedPrerenderCss;
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS (${Math.round(optimizedPrerenderCss.length / 1024)}KB)`);
} else {
// Fallback to external loading if prerender.css doesn't exist yet
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ prerender.css not found for inlining, using async loading`);
}
} catch (error) {
// Fallback to external loading
additionalTags += `<link rel="preload" href="/prerender.css" as="style" onload="this.onload=null;this.rel='stylesheet'">`;
additionalTags += `<noscript><link rel="stylesheet" href="/prerender.css"></noscript>`;
if (!suppressLogs) console.log(` ⚠️ Error reading prerender.css: ${error.message}, using async loading`);
}
// Add JavaScript files
webpackEntrypoints.js.forEach((jsFile) => {
if (!existingScriptSrcs.includes(jsFile)) {
additionalTags += `<script src="${jsFile}"></script>`;
}
});
} else {
// In development, try to inline prerender CSS as well
try {
const prerenderCssPath = path.resolve(__dirname, "..", outputDir, "prerender.css");
if (fs.existsSync(prerenderCssPath)) {
const prerenderCssContent = fs.readFileSync(prerenderCssPath, "utf8");
const optimizedCss = optimizeCss(prerenderCssContent);
inlinedCss += optimizedCss;
if (!suppressLogs) console.log(` ✅ Inlined prerender CSS in development (${Math.round(optimizedCss.length / 1024)}KB)`);
} else {
// Fallback to external loading
additionalTags += `<link rel="stylesheet" href="/prerender.css">`;
}
} catch (error) {
// Fallback to external loading
additionalTags += `<link rel="stylesheet" href="/prerender.css">`;
}
}
// Create script to save prerendered content to window object for fallback use
const prerenderFallbackScript = `
<script>
// Save prerendered content to window object for SocketProvider fallback
window.__PRERENDER_FALLBACK__ = {
path: '${location}',
content: ${JSON.stringify(renderedMarkup)},
timestamp: ${Date.now()}
};
</script>
`;
// @note Create script to populate window.productCache with ONLY the static category tree
let productCacheScript = '';
if (typeof global !== "undefined" && global.window && global.window.productCache) {
// Only include the static categoryTree_209, not any dynamic data that gets added during rendering
const staticCache = {};
if (global.window.productCache.categoryTree_209) {
staticCache.categoryTree_209 = global.window.productCache.categoryTree_209;
}
const staticCacheData = JSON.stringify(staticCache);
productCacheScript = `
<script>
// Populate window.productCache with static category tree only
window.productCache = ${staticCacheData};
</script>
`;
}
// Combine all CSS (global + inlined) into a single optimized style tag
const combinedCss = globalCss + (inlinedCss ? '\n' + inlinedCss : '');
const combinedCssTag = combinedCss ? `<style type="text/css">${combinedCss}</style>` : '';
// Add resource hints for better performance
const resourceHints = `
<meta name="viewport" content="width=device-width, initial-scale=1">
`;
template = template.replace(
"</head>",
`${resourceHints}${combinedCssTag}${additionalTags}${metaTags}${prerenderFallbackScript}${productCacheScript}</head>`
);
const rootDivRegex = /<div id="root"[\s\S]*?>[\s\S]*?<\/div>/;
const replacementHtml = `<div id="root">${renderedMarkup}</div>`;
let newHtml;
if (rootDivRegex.test(template)) {
newHtml = template.replace(rootDivRegex, replacementHtml);
} else {
newHtml = template.replace("<body>", `<body>${replacementHtml}`);
}
const outputPath = path.resolve(__dirname, "..", outputDir, filename);
// Ensure directory exists for nested paths
const outputDirPath = path.dirname(outputPath);
if (!fs.existsSync(outputDirPath)) {
fs.mkdirSync(outputDirPath, { recursive: true });
}
fs.writeFileSync(outputPath, newHtml);
if (!suppressLogs) {
console.log(`${description} prerendered to ${outputPath}`);
console.log(` - Markup length: ${renderedMarkup.length} characters`);
console.log(` - CSS rules: ${Object.keys(cache.inserted).length}`);
console.log(` - Total inlined CSS: ${Math.round(combinedCss.length / 1024)}KB`);
console.log(` - Render-blocking CSS eliminated: ${inlinedCss ? 'YES' : 'NO'}`);
console.log(` - Fallback content saved to window.__PRERENDER_FALLBACK__`);
}
return true;
};
module.exports = {
renderPage,
};

881
prerender/seo.cjs Normal file
View File

@@ -0,0 +1,881 @@
const generateProductMetaTags = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for meta (remove HTML tags and limit length)
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.substring(0, 160)
: `${product.name} - Art.-Nr.: ${product.articleNumber}`;
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${cleanDescription}">
<meta name="keywords" content="${product.name}, ${
product.manufacturer || ""
}, ${product.articleNumber}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${product.name}">
<meta property="og:description" content="${cleanDescription}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${productUrl}">
<meta property="og:type" content="product">
<meta property="og:site_name" content="${config.siteName}">
<meta property="product:price:amount" content="${product.price}">
<meta property="product:price:currency" content="${config.currency}">
<meta property="product:availability" content="${
product.available ? "in stock" : "out of stock"
}">
${product.gtin ? `<meta property="product:gtin" content="${product.gtin}">` : ''}
${product.articleNumber ? `<meta property="product:retailer_item_id" content="${product.articleNumber}">` : ''}
${product.manufacturer ? `<meta property="product:brand" content="${product.manufacturer}">` : ''}
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${product.name}">
<meta name="twitter:description" content="${cleanDescription}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${productUrl}">
`;
};
const generateProductJsonLd = (product, baseUrl, config) => {
const productUrl = `${baseUrl}/Artikel/${product.seoName}`;
const imageUrl =
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Clean description for JSON-LD (remove HTML tags)
const cleanDescription = product.description
? product.description.replace(/<[^>]*>/g, "").replace(/\n/g, " ")
: product.name;
// Calculate price valid date (current date + 3 months)
const priceValidDate = new Date();
priceValidDate.setMonth(priceValidDate.getMonth() + 3);
const jsonLd = {
"@context": "https://schema.org/",
"@type": "Product",
name: product.name,
image: [imageUrl],
description: cleanDescription,
sku: product.articleNumber,
...(product.gtin && { gtin: product.gtin }),
brand: {
"@type": "Brand",
name: product.manufacturer || "Unknown",
},
offers: {
"@type": "Offer",
url: productUrl,
priceCurrency: config.currency,
price: product.price.toString(),
priceValidUntil: priceValidDate.toISOString().split("T")[0],
itemCondition: "https://schema.org/NewCondition",
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: {
"@type": "Organization",
name: config.brandName,
},
},
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateCategoryJsonLd = (category, products = [], baseUrl, config) => {
const categoryUrl = `${baseUrl}/Kategorie/${category.seoName}`;
const jsonLd = {
"@context": "https://schema.org/",
"@type": "CollectionPage",
name: category.name,
url: categoryUrl,
description: `${category.name} - Entdecken Sie unsere Auswahl an hochwertigen Produkten`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: category.name,
item: categoryUrl,
},
],
},
};
// Add product list if products are available
if (products && products.length > 0) {
jsonLd.mainEntity = {
"@type": "ItemList",
numberOfItems: products.length,
itemListElement: products.slice(0, 20).map((product, index) => ({
"@type": "ListItem",
position: index + 1,
item: {
"@type": "Product",
name: product.name,
url: `${baseUrl}/Artikel/${product.seoName}`,
image:
product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList
.split(",")[0]
.trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`,
offers: {
"@type": "Offer",
price: product.price.toString(),
priceCurrency: config.currency,
availability: product.available
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
},
})),
};
}
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateHomepageMetaTags = (baseUrl, config) => {
const description = config.descriptions.long;
const keywords = config.keywords;
const imageUrl = `${baseUrl}${config.images.logo}`;
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${description}">
<meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="${config.descriptions.short}">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${baseUrl}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${config.siteName}">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${config.descriptions.short}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${baseUrl}">
`;
};
const generateHomepageJsonLd = (baseUrl, config) => {
const jsonLd = {
"@context": "https://schema.org/",
"@type": "WebSite",
name: config.brandName,
url: baseUrl,
description: config.descriptions.long,
publisher: {
"@type": "Organization",
name: config.brandName,
url: baseUrl,
logo: {
"@type": "ImageObject",
url: `${baseUrl}${config.images.logo}`,
},
},
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${baseUrl}/search?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
mainEntity: {
"@type": "WebPage",
name: "Sitemap",
url: `${baseUrl}/sitemap`,
description: "Vollständige Sitemap mit allen Kategorien und Seiten",
},
sameAs: [
// Add your social media URLs here if available
],
};
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateSitemapJsonLd = (allCategories = [], baseUrl, config) => {
const jsonLd = {
"@context": "https://schema.org/",
"@type": "WebPage",
name: "Sitemap",
url: `${baseUrl}/sitemap`,
description: `Sitemap - Übersicht aller Kategorien und Seiten auf ${config.siteName}`,
breadcrumb: {
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: baseUrl,
},
{
"@type": "ListItem",
position: 2,
name: "Sitemap",
item: `${baseUrl}/sitemap`,
},
],
},
};
// Add all categories as site navigation elements
if (allCategories && allCategories.length > 0) {
jsonLd.mainEntity = {
"@type": "SiteNavigationElement",
name: "Kategorien",
hasPart: allCategories.map((category) => ({
"@type": "SiteNavigationElement",
name: category.name,
url: `${baseUrl}/Kategorie/${category.seoName}`,
description: `${category.name} Kategorie`,
})),
};
}
return `<script type="application/ld+json">${JSON.stringify(
jsonLd
)}</script>`;
};
const generateXmlSitemap = (allCategories = [], allProducts = [], baseUrl) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
`;
// Homepage
sitemap += ` <url>
<loc>${baseUrl}/</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
`;
// Static pages
const staticPages = [
{ path: "/datenschutz", changefreq: "monthly", priority: "0.3" },
{ path: "/impressum", changefreq: "monthly", priority: "0.3" },
{ path: "/batteriegesetzhinweise", changefreq: "monthly", priority: "0.3" },
{ path: "/widerrufsrecht", changefreq: "monthly", priority: "0.3" },
{ path: "/sitemap", changefreq: "weekly", priority: "0.5" },
{ path: "/agb", changefreq: "monthly", priority: "0.3" },
{ path: "/Konfigurator", changefreq: "weekly", priority: "0.8" },
];
staticPages.forEach((page) => {
sitemap += ` <url>
<loc>${baseUrl}${page.path}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>
`;
});
// Category pages
allCategories.forEach((category) => {
if (category.seoName) {
sitemap += ` <url>
<loc>${baseUrl}/Kategorie/${category.seoName}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
`;
}
});
// Product pages
allProducts.forEach((productSeoName) => {
sitemap += ` <url>
<loc>${baseUrl}/Artikel/${productSeoName}</loc>
<lastmod>${currentDate}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
`;
});
sitemap += `</urlset>`;
return sitemap;
};
const generateKonfiguratorMetaTags = (baseUrl, config) => {
const description = "Unser interaktiver Growbox Konfigurator hilft dir dabei, das perfekte Indoor Growing Setup zusammenzustellen. Wähle aus verschiedenen Growbox-Größen, Beleuchtung, Belüftung und Extras. Bundle-Rabatte bis 36%!";
const keywords = "Growbox Konfigurator, Indoor Growing, Growzelt, Beleuchtung, Belüftung, Growbox Setup, Indoor Garden";
const imageUrl = `${baseUrl}${config.images.placeholder}`; // Placeholder image
return `
<!-- SEO Meta Tags -->
<meta name="description" content="${description}">
<meta name="keywords" content="${keywords}">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Growbox Konfigurator - Stelle dein perfektes Indoor Grow Setup zusammen">
<meta property="og:description" content="${description}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:url" content="${baseUrl}/Konfigurator">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${config.siteName}">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Growbox Konfigurator - Indoor Grow Setup">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${imageUrl}">
<!-- Additional Meta Tags -->
<meta name="robots" content="index, follow">
<link rel="canonical" href="${baseUrl}/Konfigurator">
`;
};
const generateRobotsTxt = (baseUrl) => {
const robotsTxt = `User-agent: *
Allow: /
Sitemap: ${baseUrl}/sitemap.xml
Crawl-delay: 0
`;
return robotsTxt;
};
const generateProductsXml = (allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString();
// Validate input
if (!Array.isArray(allProductsData) || allProductsData.length === 0) {
throw new Error("No valid product data provided");
}
// Category mapping function
const getGoogleProductCategory = (categoryId) => {
const categoryMappings = {
// Seeds & Plants
689: "Home & Garden > Plants > Seeds",
706: "Home & Garden > Plants", // Stecklinge (cuttings)
376: "Home & Garden > Plants > Plant & Herb Growing Kits", // Grow-Sets
// Headshop & Accessories
709: "Arts & Entertainment > Hobbies & Creative Arts", // Headshop
711: "Arts & Entertainment > Hobbies & Creative Arts", // Bongs
714: "Arts & Entertainment > Hobbies & Creative Arts", // Zubehör
748: "Arts & Entertainment > Hobbies & Creative Arts", // Köpfe
749: "Arts & Entertainment > Hobbies & Creative Arts", // Chillums / Diffusoren / Kupplungen
896: "Electronics > Electronics Accessories", // Vaporizer
710: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Grinder
// Measuring & Packaging
186: "Business & Industrial", // Wiegen & Verpacken
187: "Business & Industrial > Science & Laboratory > Lab Equipment", // Waagen
346: "Home & Garden > Kitchen & Dining > Food Storage", // Vakuumbeutel
355: "Home & Garden > Kitchen & Dining > Food Storage", // Boveda & Integra Boost
407: "Home & Garden > Kitchen & Dining > Food Storage", // Grove Bags
449: "Home & Garden > Kitchen & Dining > Food Storage", // Cliptütchen
539: "Home & Garden > Kitchen & Dining > Food Storage", // Gläser & Dosen
// Lighting & Equipment
694: "Home & Garden > Lighting", // Lampen
261: "Home & Garden > Lighting", // Lampenzubehör
// Plants & Growing
691: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger
692: "Home & Garden > Lawn & Garden > Fertilizers", // Dünger - Zubehör
693: "Sporting Goods > Outdoor Recreation > Camping & Hiking > Tents", // Zelte
// Pots & Containers
219: "Home & Garden > Decor > Planters & Pots", // Töpfe
220: "Home & Garden > Decor > Planters & Pots", // Untersetzer
301: "Home & Garden > Decor > Planters & Pots", // Stofftöpfe
317: "Home & Garden > Decor > Planters & Pots", // Air-Pot
364: "Home & Garden > Decor > Planters & Pots", // Kunststofftöpfe
292: "Home & Garden > Decor > Planters & Pots", // Trays & Fluttische
// Ventilation & Climate
703: "Home & Garden > Outdoor Power Tools", // Abluft-Sets
247: "Home & Garden > Outdoor Power Tools", // Belüftung
214: "Home & Garden > Outdoor Power Tools", // Umluft-Ventilatoren
308: "Home & Garden > Outdoor Power Tools", // Ab- und Zuluft
609: "Home & Garden > Outdoor Power Tools", // Schalldämpfer
248: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Aktivkohlefilter
392: "Home & Garden > Pool & Spa > Pool & Spa Filters", // Zuluftfilter
658: "Home & Garden > Climate Control > Dehumidifiers", // Luftbe- und entfeuchter
310: "Home & Garden > Climate Control > Heating", // Heizmatten
379: "Home & Garden > Household Supplies > Air Fresheners", // Geruchsneutralisation
// Irrigation & Watering
221: "Home & Garden > Lawn & Garden > Watering Equipment", // Bewässerung
250: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
297: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpen
354: "Home & Garden > Lawn & Garden > Watering Equipment", // Sprüher
372: "Home & Garden > Lawn & Garden > Watering Equipment", // AutoPot
389: "Home & Garden > Lawn & Garden > Watering Equipment", // Blumat
405: "Home & Garden > Lawn & Garden > Watering Equipment", // Schläuche
425: "Home & Garden > Lawn & Garden > Watering Equipment", // Wassertanks
480: "Home & Garden > Lawn & Garden > Watering Equipment", // Tropfer
519: "Home & Garden > Lawn & Garden > Watering Equipment", // Pumpsprüher
// Growing Media & Soils
242: "Home & Garden > Lawn & Garden > Fertilizers", // Böden
243: "Home & Garden > Lawn & Garden > Fertilizers", // Erde
269: "Home & Garden > Lawn & Garden > Fertilizers", // Kokos
580: "Home & Garden > Lawn & Garden > Fertilizers", // Perlite & Blähton
// Propagation & Starting
286: "Home & Garden > Plants", // Anzucht
298: "Home & Garden > Plants", // Steinwolltrays
421: "Home & Garden > Plants", // Vermehrungszubehör
489: "Home & Garden > Plants", // EazyPlug & Jiffy
359: "Home & Garden > Outdoor Structures > Greenhouses", // Gewächshäuser
// Tools & Equipment
373: "Home & Garden > Tools > Hand Tools", // GrowTool
403: "Home & Garden > Kitchen & Dining > Kitchen Tools & Utensils", // Messbecher & mehr
259: "Home & Garden > Tools > Hand Tools", // Pressen
280: "Home & Garden > Tools > Hand Tools", // Erntescheeren
258: "Home & Garden > Tools", // Ernte & Verarbeitung
278: "Home & Garden > Tools", // Extraktion
302: "Home & Garden > Tools", // Erntemaschinen
// Hardware & Plumbing
222: "Hardware > Plumbing", // PE-Teile
374: "Hardware > Plumbing > Plumbing Fittings", // Verbindungsteile
// Electronics & Control
314: "Electronics > Electronics Accessories", // Steuergeräte
408: "Electronics > Electronics Accessories", // GrowControl
344: "Business & Industrial > Science & Laboratory > Lab Equipment", // Messgeräte
555: "Business & Industrial > Science & Laboratory > Lab Equipment > Microscopes", // Mikroskope
// Camping & Outdoor
226: "Sporting Goods > Outdoor Recreation > Camping & Hiking", // Zeltzubehör
// Plant Care & Protection
239: "Home & Garden > Lawn & Garden > Pest Control", // Pflanzenschutz
240: "Home & Garden > Plants", // Anbauzubehör
// Office & Media
424: "Office Supplies > Labels", // Etiketten & Schilder
387: "Media > Books", // Literatur
// General categories
705: "Home & Garden", // Set-Konfigurator
686: "Home & Garden", // Zubehör
741: "Home & Garden", // Zubehör
294: "Home & Garden", // Zubehör
695: "Home & Garden", // Zubehör
293: "Home & Garden", // Trockennetze
4: "Home & Garden", // Sonstiges
450: "Home & Garden", // Restposten
};
return categoryMappings[categoryId] || "Home & Garden > Plants";
};
let productsXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">
<channel>
<title>${config.descriptions.short}</title>
<link>${baseUrl}</link>
<description>${config.descriptions.short}</description>
<lastBuildDate>${currentDate}</lastBuildDate>
<language>${config.language}</language>`;
// Helper function to clean text content of problematic characters
const cleanTextContent = (text) => {
if (!text) return "";
return text.toString()
// Remove HTML tags
.replace(/<[^>]*>/g, "")
// Remove non-printable characters and control characters
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '')
// Remove BOM and other Unicode formatting characters
.replace(/[\uFEFF\u200B-\u200D\u2060]/g, '')
// Replace multiple whitespace with single space
.replace(/\s+/g, ' ')
// Remove leading/trailing whitespace
.trim();
};
// Helper function to properly escape XML content and remove invalid characters
const escapeXml = (unsafe) => {
if (!unsafe) return "";
// Convert to string and remove invalid XML characters
const cleaned = unsafe.toString()
// Remove control characters except tab (0x09), newline (0x0A), and carriage return (0x0D)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
// Remove invalid Unicode characters and surrogates
.replace(/[\uD800-\uDFFF]/g, '')
// Remove other problematic characters
.replace(/[\uFFFE\uFFFF]/g, '')
// Normalize whitespace
.replace(/\s+/g, ' ')
.trim();
// Escape XML entities
return cleaned
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
};
let processedCount = 0;
let skippedCount = 0;
// Category IDs to skip (seeds, plants, headshop items)
const skipCategoryIds = [689, 706, 709, 711, 714, 748, 749, 896, 710];
// Add each product as an item
allProductsData.forEach((product, index) => {
try {
// Skip products without essential data
if (!product || !product.seoName) {
skippedCount++;
return;
}
// Skip products from excluded categories
const productCategoryId = product.categoryId || product.category_id || product.category || null;
if (productCategoryId && skipCategoryIds.includes(parseInt(productCategoryId))) {
skippedCount++;
return;
}
// Skip products without GTIN
if (!product.gtin || !product.gtin.toString().trim()) {
skippedCount++;
return;
}
// Skip products without pictures
if (!product.pictureList || !product.pictureList.trim()) {
skippedCount++;
return;
}
// Clean description for feed (remove HTML tags and limit length)
const rawDescription = product.description
? cleanTextContent(product.description).substring(0, 500)
: `${product.name || 'Product'} - Art.-Nr.: ${product.articleNumber || 'N/A'}`;
const cleanDescription = escapeXml(rawDescription) || "Produktbeschreibung nicht verfügbar";
// Clean product name
const rawName = product.name || "Unnamed Product";
const cleanName = escapeXml(cleanTextContent(rawName)) || "Unnamed Product";
// Validate essential fields
if (!cleanName || cleanName.length < 2) {
skippedCount++;
return;
}
// Generate product URL
const productUrl = `${baseUrl}/Artikel/${encodeURIComponent(product.seoName)}`;
// Generate image URL
const imageUrl = product.pictureList && product.pictureList.trim()
? `${baseUrl}/assets/images/prod${product.pictureList.split(",")[0].trim()}.jpg`
: `${baseUrl}/assets/images/nopicture.jpg`;
// Generate brand (manufacturer)
const rawBrand = product.manufacturer || config.brandName;
const brand = escapeXml(cleanTextContent(rawBrand));
// Generate condition (always new for this type of shop)
const condition = "new";
// Generate availability
const availability = product.available ? "in stock" : "out of stock";
// Generate price (ensure it's a valid number)
const price = product.price && !isNaN(product.price)
? `${parseFloat(product.price).toFixed(2)} ${config.currency}`
: `0.00 ${config.currency}`;
// Generate GTIN/EAN if available (using articleNumber as fallback)
const rawGtin = product.gtin || "";
const gtin = escapeXml(rawGtin.toString().trim());
// Generate product ID (using articleNumber or seoName)
const rawProductId = product.articleNumber || product.seoName || `product_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
const productId = escapeXml(rawProductId.toString().trim()) || `fallback_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
// Get Google product category based on product's category ID
const categoryId = product.categoryId || product.category_id || product.category || null;
const googleCategory = getGoogleProductCategory(categoryId);
const escapedGoogleCategory = escapeXml(googleCategory);
// Build item XML with proper formatting
productsXml += `
<item>
<g:id>${productId}</g:id>
<g:title>${cleanName}</g:title>
<g:description>${cleanDescription}</g:description>
<g:link>${productUrl}</g:link>
<g:image_link>${imageUrl}</g:image_link>
<g:condition>${condition}</g:condition>
<g:availability>${availability}</g:availability>
<g:price>${price}</g:price>
<g:shipping>
<g:country>${config.country}</g:country>
<g:service>${config.shipping.defaultService}</g:service>
<g:price>${config.shipping.defaultCost}</g:price>
</g:shipping>
<g:brand>${brand}</g:brand>
<g:google_product_category>${escapedGoogleCategory}</g:google_product_category>
<g:product_type>Gartenbedarf</g:product_type>`;
// Add GTIN if available
if (gtin && gtin.trim()) {
productsXml += `
<g:gtin>${gtin}</g:gtin>`;
}
// Add weight if available
if (product.weight && !isNaN(product.weight)) {
productsXml += `
<g:shipping_weight>${parseFloat(product.weight).toFixed(2)} g</g:shipping_weight>`;
}
productsXml += `
</item>`;
processedCount++;
} catch (itemError) {
console.log(` ⚠️ Skipped product ${index + 1}: ${itemError.message}`);
skippedCount++;
}
});
productsXml += `
</channel>
</rss>`;
console.log(` 📊 Processing summary: ${processedCount} products included, ${skippedCount} skipped`);
return productsXml;
};
const generateLlmsTxt = (allCategories = [], allProductsData = [], baseUrl, config) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
// Group products by category for statistics
const productsByCategory = {};
allProductsData.forEach((product) => {
const categoryId = product.categoryId || 'uncategorized';
if (!productsByCategory[categoryId]) {
productsByCategory[categoryId] = [];
}
productsByCategory[categoryId].push(product);
});
// Find category names for organization
const categoryMap = {};
allCategories.forEach((cat) => {
categoryMap[cat.id] = cat.name;
});
let llmsTxt = `# ${config.siteName} - Site Map for LLMs
Generated: ${currentDate}
Base URL: ${baseUrl}
## About ${config.brandName}
SeedHeads is a German online shop specializing in high-quality seeds, plants, and gardening supplies. We offer a comprehensive range of products for indoor and outdoor growing, including seeds, cuttings, grow equipment, lighting, ventilation, fertilizers, and accessories.
## Site Structure
### Static Pages
- **Home** - ${baseUrl}/
- **Datenschutz (Privacy Policy)** - ${baseUrl}/datenschutz
- **Impressum (Legal Notice)** - ${baseUrl}/impressum
- **AGB (Terms & Conditions)** - ${baseUrl}/agb
- **Widerrufsrecht (Right of Withdrawal)** - ${baseUrl}/widerrufsrecht
- **Batteriegesetzhinweise (Battery Law Notice)** - ${baseUrl}/batteriegesetzhinweise
- **Sitemap** - ${baseUrl}/sitemap
- **Growbox Konfigurator** - ${baseUrl}/Konfigurator - Interactive tool to configure grow box setups with bundle discounts
- **Profile** - ${baseUrl}/profile - User account and order management
### Site Features
- **Language**: German (${config.language})
- **Currency**: ${config.currency} (Euro)
- **Shipping**: ${config.country}
- **Payment Methods**: Credit Cards, PayPal, Bank Transfer, Cash on Delivery, Cash on Pickup
### Product Categories (${allCategories.length} categories)
`;
// Add categories with links to their detailed LLM files
allCategories.forEach((category) => {
if (category.seoName) {
const productCount = productsByCategory[category.id]?.length || 0;
const categorySlug = category.seoName.toLowerCase().replace(/[^a-z0-9]/g, '-');
llmsTxt += `#### ${category.name} (${productCount} products)
- **Product Catalog**: ${baseUrl}/llms-${categorySlug}.txt
`;
}
});
llmsTxt += `
---
*This sitemap is automatically generated during the site build process and includes all publicly accessible content. For technical inquiries, please refer to our contact information in the Impressum.*
`;
return llmsTxt;
};
const generateCategoryLlmsTxt = (category, categoryProducts = [], baseUrl, config) => {
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
let categoryLlmsTxt = `# ${category.name} - Product Catalog
Generated: ${currentDate}
Base URL: ${baseUrl}
Category: ${category.name} (ID: ${category.id})
Category URL: ${baseUrl}/Kategorie/${category.seoName}
## Category Overview
This file contains all products in the "${category.name}" category from ${config.siteName}.
**Statistics:**
- **Total Products**: ${categoryProducts.length}
- **Category ID**: ${category.id}
- **Category URL**: ${baseUrl}/Kategorie/${category.seoName}
- **Back to Main Sitemap**: ${baseUrl}/llms.txt
`;
if (categoryProducts.length > 0) {
categoryProducts.forEach((product, index) => {
if (product.seoName) {
// Clean description for markdown (remove HTML tags and limit length)
const cleanDescription = product.description
? product.description
.replace(/<[^>]*>/g, "")
.replace(/\n/g, " ")
.trim()
.substring(0, 300)
: "";
categoryLlmsTxt += `## ${index + 1}. ${product.name}
- **Product URL**: ${baseUrl}/Artikel/${product.seoName}
- **Article Number**: ${product.articleNumber || 'N/A'}
- **Price**: €${product.price || '0.00'}
- **Brand**: ${product.manufacturer || config.brandName}
- **Availability**: ${product.available ? 'In Stock' : 'Out of Stock'}`;
if (product.gtin) {
categoryLlmsTxt += `
- **GTIN**: ${product.gtin}`;
}
if (product.weight && !isNaN(product.weight)) {
categoryLlmsTxt += `
- **Weight**: ${product.weight}g`;
}
if (cleanDescription) {
categoryLlmsTxt += `
**Description:**
${cleanDescription}${product.description && product.description.length > 300 ? '...' : ''}`;
}
categoryLlmsTxt += `
---
`;
}
});
} else {
categoryLlmsTxt += `## No Products Available
This category currently contains no products.
`;
}
categoryLlmsTxt += `---
*This category product list is automatically generated during the site build process. Product availability and pricing are updated in real-time on the main website.*
`;
return categoryLlmsTxt;
};
module.exports = {
generateProductMetaTags,
generateProductJsonLd,
generateCategoryJsonLd,
generateHomepageMetaTags,
generateHomepageJsonLd,
generateSitemapJsonLd,
generateKonfiguratorMetaTags,
generateXmlSitemap,
generateRobotsTxt,
generateProductsXml,
generateLlmsTxt,
generateCategoryLlmsTxt,
};

130
prerender/utils.cjs Normal file
View File

@@ -0,0 +1,130 @@
const fs = require('fs');
const path = require('path');
// Helper function to collect all categories from the tree
const collectAllCategories = (categoryNode, categories = []) => {
if (!categoryNode) return categories;
// Add current category (skip root category 209)
if (categoryNode.id !== 209) {
categories.push({
id: categoryNode.id,
name: categoryNode.name,
seoName: categoryNode.seoName,
parentId: categoryNode.parentId
});
}
// Recursively add children
if (categoryNode.children) {
for (const child of categoryNode.children) {
collectAllCategories(child, categories);
}
}
return categories;
};
// Advanced CSS minification and optimization
const optimizeCss = (cssContent) => {
if (!cssContent || typeof cssContent !== 'string') {
return '';
}
try {
let optimized = cssContent
// Remove comments (/* ... */)
.replace(/\/\*[\s\S]*?\*\//g, '')
// Remove unnecessary whitespace but preserve structure
.replace(/\s*{\s*/g, '{')
.replace(/;\s*}/g, '}')
.replace(/}\s*/g, '}')
.replace(/,\s*/g, ',')
.replace(/:\s*/g, ':')
.replace(/;\s*/g, ';')
// Remove empty rules
.replace(/[^}]*\{\s*\}/g, '')
// Normalize multiple spaces/tabs/newlines
.replace(/\s+/g, ' ')
// Remove leading/trailing whitespace
.trim();
// Remove redundant semicolons before closing braces
optimized = optimized.replace(/;+}/g, '}');
// Remove empty media queries
optimized = optimized.replace(/@media[^{]*\{\s*\}/g, '');
return optimized;
} catch (error) {
console.warn(`⚠️ CSS optimization failed: ${error.message}`);
return cssContent; // Return original if optimization fails
}
};
// Extract critical CSS selectors (basic implementation)
const extractCriticalCss = (cssContent, criticalSelectors = []) => {
if (!cssContent || !criticalSelectors.length) {
return { critical: '', nonCritical: cssContent };
}
try {
const rules = cssContent.match(/[^{}]+\{[^{}]*\}/g) || [];
let critical = '';
let nonCritical = '';
rules.forEach(rule => {
const selector = rule.split('{')[0].trim();
const isCritical = criticalSelectors.some(criticalSel => {
return selector.includes(criticalSel) ||
selector.includes('body') ||
selector.includes('html') ||
selector.includes(':root') ||
selector.includes('@font-face') ||
selector.includes('@import');
});
if (isCritical) {
critical += rule;
} else {
nonCritical += rule;
}
});
return {
critical: optimizeCss(critical),
nonCritical: optimizeCss(nonCritical)
};
} catch (error) {
console.warn(`⚠️ Critical CSS extraction failed: ${error.message}`);
return { critical: cssContent, nonCritical: '' };
}
};
const writeCombinedCssFile = (globalCssCollection, outputDir) => {
const combinedCss = Array.from(globalCssCollection).join('\n');
// Optimize the combined CSS
const optimizedCss = optimizeCss(combinedCss);
const cssFilePath = path.resolve(__dirname, '..', outputDir, 'prerender.css');
fs.writeFileSync(cssFilePath, optimizedCss);
const originalSize = combinedCss.length;
const optimizedSize = optimizedCss.length;
const savings = originalSize - optimizedSize;
const savingsPercent = originalSize > 0 ? Math.round((savings / originalSize) * 100) : 0;
console.log(`✅ Combined CSS file written to ${cssFilePath}`);
console.log(` - Total CSS rules: ${globalCssCollection.size}`);
console.log(` - Original size: ${Math.round(originalSize / 1024)}KB`);
console.log(` - Optimized size: ${Math.round(optimizedSize / 1024)}KB`);
console.log(` - Space saved: ${Math.round(savings / 1024)}KB (${savingsPercent}%)`);
};
module.exports = {
collectAllCategories,
writeCombinedCssFile,
optimizeCss,
extractCriticalCss
};