fix: optimize i18n page generation to prevent OOM during Docker build
This commit is contained in:
@@ -26,7 +26,7 @@ ENV COMPRESSION_MODE=$COMPRESSION_MODE
|
|||||||
ARG BASE_URL
|
ARG BASE_URL
|
||||||
ENV BASE_URL=$BASE_URL
|
ENV BASE_URL=$BASE_URL
|
||||||
|
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
||||||
|
|
||||||
RUN npm run build:with-docs
|
RUN npm run build:with-docs
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=4096' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs",
|
"build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs",
|
||||||
"build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js",
|
"build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js",
|
||||||
"build:gzip": "COMPRESSION_MODE=g npm run build",
|
"build:gzip": "COMPRESSION_MODE=g npm run build",
|
||||||
"build:brotli": "COMPRESSION_MODE=b npm run build",
|
"build:brotli": "COMPRESSION_MODE=b npm run build",
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ const KEY_MAPPING = {
|
|||||||
404: 'notFound',
|
404: 'notFound',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function loadAllTranslations() {
|
||||||
|
const translations = {};
|
||||||
|
for (const lang of languages) {
|
||||||
|
if (lang === 'en') continue;
|
||||||
|
const commonPath = path.join(LOCALES_DIR, `${lang}/common.json`);
|
||||||
|
const toolsPath = path.join(LOCALES_DIR, `${lang}/tools.json`);
|
||||||
|
translations[lang] = {
|
||||||
|
common: fs.existsSync(commonPath)
|
||||||
|
? JSON.parse(fs.readFileSync(commonPath, 'utf-8'))
|
||||||
|
: {},
|
||||||
|
tools: fs.existsSync(toolsPath)
|
||||||
|
? JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO@ALAM: Let users build only a single language
|
// TODO@ALAM: Let users build only a single language
|
||||||
function buildUrl(langPrefix, pagePath) {
|
function buildUrl(langPrefix, pagePath) {
|
||||||
const parts = [SITE_URL];
|
const parts = [SITE_URL];
|
||||||
@@ -33,179 +51,218 @@ function buildUrl(langPrefix, pagePath) {
|
|||||||
return parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL;
|
return parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processFileForLanguage(
|
||||||
|
originalContent,
|
||||||
|
file,
|
||||||
|
lang,
|
||||||
|
translations,
|
||||||
|
langDir
|
||||||
|
) {
|
||||||
|
const filenameNoExt = file.replace('.html', '');
|
||||||
|
let translationKey = toCamelCase(filenameNoExt);
|
||||||
|
if (KEY_MAPPING[filenameNoExt]) {
|
||||||
|
translationKey = KEY_MAPPING[filenameNoExt];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tools } = translations[lang];
|
||||||
|
const dom = new JSDOM(originalContent);
|
||||||
|
const document = dom.window.document;
|
||||||
|
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
|
||||||
|
let title = null;
|
||||||
|
let description = null;
|
||||||
|
|
||||||
|
if (tools[translationKey]) {
|
||||||
|
title =
|
||||||
|
tools[translationKey].pageTitle ||
|
||||||
|
(tools[translationKey].name
|
||||||
|
? `${tools[translationKey].name} - BentoPDF`
|
||||||
|
: null);
|
||||||
|
description = tools[translationKey].subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
document.title = title;
|
||||||
|
const metaTitle = document.querySelector('meta[property="og:title"]');
|
||||||
|
if (metaTitle) metaTitle.content = title;
|
||||||
|
const metaTwitterTitle = document.querySelector(
|
||||||
|
'meta[name="twitter:title"]'
|
||||||
|
);
|
||||||
|
if (metaTwitterTitle) metaTwitterTitle.content = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
const metaDesc = document.querySelector('meta[name="description"]');
|
||||||
|
if (metaDesc) metaDesc.content = description;
|
||||||
|
const metaOgDesc = document.querySelector(
|
||||||
|
'meta[property="og:description"]'
|
||||||
|
);
|
||||||
|
if (metaOgDesc) metaOgDesc.content = description;
|
||||||
|
const metaTwitterDesc = document.querySelector(
|
||||||
|
'meta[name="twitter:description"]'
|
||||||
|
);
|
||||||
|
if (metaTwitterDesc) metaTwitterDesc.content = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll('link[rel="alternate"][hreflang]')
|
||||||
|
.forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt;
|
||||||
|
|
||||||
|
languages.forEach((l) => {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'alternate';
|
||||||
|
link.hreflang = l;
|
||||||
|
link.href = buildUrl(l === 'en' ? '' : l, pagePath);
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultLink = document.createElement('link');
|
||||||
|
defaultLink.rel = 'alternate';
|
||||||
|
defaultLink.hreflang = 'x-default';
|
||||||
|
defaultLink.href = buildUrl('', pagePath);
|
||||||
|
document.head.appendChild(defaultLink);
|
||||||
|
|
||||||
|
let canonical = document.querySelector('link[rel="canonical"]');
|
||||||
|
if (!canonical) {
|
||||||
|
canonical = document.createElement('link');
|
||||||
|
canonical.rel = 'canonical';
|
||||||
|
document.head.appendChild(canonical);
|
||||||
|
}
|
||||||
|
canonical.href = buildUrl(lang, pagePath);
|
||||||
|
|
||||||
|
const links = document.querySelectorAll('a[href]');
|
||||||
|
links.forEach((link) => {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
href.startsWith('http') ||
|
||||||
|
href.startsWith('//') ||
|
||||||
|
href.startsWith('#') ||
|
||||||
|
href.startsWith('mailto:') ||
|
||||||
|
href.startsWith('tel:') ||
|
||||||
|
href.startsWith('javascript:')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href.startsWith('/assets/') || href.includes('/assets/')) return;
|
||||||
|
|
||||||
|
const langPrefixRegex = new RegExp(
|
||||||
|
`^(${BASE_PATH})?/(${languages.join('|')})(/|$)`
|
||||||
|
);
|
||||||
|
if (langPrefixRegex.test(href)) return;
|
||||||
|
|
||||||
|
let newHref;
|
||||||
|
if (href.startsWith('/')) {
|
||||||
|
const pathWithoutBase = href.startsWith(BASE_PATH)
|
||||||
|
? href.slice(BASE_PATH.length)
|
||||||
|
: href;
|
||||||
|
newHref = `${BASE_PATH}/${lang}${pathWithoutBase}`;
|
||||||
|
} else {
|
||||||
|
newHref = `${BASE_PATH}/${lang}/${href}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
link.setAttribute('href', newHref);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = dom.serialize();
|
||||||
|
|
||||||
|
dom.window.close();
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(langDir, file), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnglishFile(filePath, originalContent) {
|
||||||
|
const filenameNoExt = path.basename(filePath, '.html');
|
||||||
|
const dom = new JSDOM(originalContent);
|
||||||
|
const document = dom.window.document;
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll('link[rel="alternate"][hreflang]')
|
||||||
|
.forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt;
|
||||||
|
|
||||||
|
languages.forEach((l) => {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'alternate';
|
||||||
|
link.hreflang = l;
|
||||||
|
link.href = buildUrl(l === 'en' ? '' : l, pagePath);
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultLink = document.createElement('link');
|
||||||
|
defaultLink.rel = 'alternate';
|
||||||
|
defaultLink.hreflang = 'x-default';
|
||||||
|
defaultLink.href = buildUrl('', pagePath);
|
||||||
|
document.head.appendChild(defaultLink);
|
||||||
|
|
||||||
|
const result = dom.serialize();
|
||||||
|
|
||||||
|
dom.window.close();
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, result);
|
||||||
|
}
|
||||||
|
|
||||||
async function generateI18nPages() {
|
async function generateI18nPages() {
|
||||||
console.log('🌍 Generating i18n pages...');
|
console.log('🌍 Generating i18n pages...');
|
||||||
console.log(` SITE_URL: ${SITE_URL}`);
|
console.log(` SITE_URL: ${SITE_URL}`);
|
||||||
console.log(` BASE_PATH: ${BASE_PATH || '/'}`);
|
console.log(` BASE_PATH: ${BASE_PATH || '/'}`);
|
||||||
|
console.log(` Languages: ${languages.length} (${languages.join(', ')})`);
|
||||||
|
|
||||||
if (!fs.existsSync(DIST_DIR)) {
|
if (!fs.existsSync(DIST_DIR)) {
|
||||||
console.error('❌ dist directory not found. Please run build first.');
|
console.error('❌ dist directory not found. Please run build first.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(' Loading translations...');
|
||||||
|
const translations = loadAllTranslations();
|
||||||
|
|
||||||
const htmlFiles = fs
|
const htmlFiles = fs
|
||||||
.readdirSync(DIST_DIR)
|
.readdirSync(DIST_DIR)
|
||||||
.filter((file) => file.endsWith('.html'));
|
.filter((file) => file.endsWith('.html'));
|
||||||
|
|
||||||
|
console.log(` Processing ${htmlFiles.length} HTML files...`);
|
||||||
|
|
||||||
|
for (const lang of languages) {
|
||||||
|
if (lang === 'en') continue;
|
||||||
|
const langDir = path.join(DIST_DIR, lang);
|
||||||
|
if (!fs.existsSync(langDir)) {
|
||||||
|
fs.mkdirSync(langDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
const total = htmlFiles.length * (languages.length - 1);
|
||||||
|
|
||||||
for (const file of htmlFiles) {
|
for (const file of htmlFiles) {
|
||||||
const filePath = path.join(DIST_DIR, file);
|
const filePath = path.join(DIST_DIR, file);
|
||||||
const originalContent = fs.readFileSync(filePath, 'utf-8');
|
const originalContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
const filenameNoExt = file.replace('.html', '');
|
|
||||||
|
|
||||||
let translationKey = toCamelCase(filenameNoExt);
|
|
||||||
if (KEY_MAPPING[filenameNoExt]) {
|
|
||||||
translationKey = KEY_MAPPING[filenameNoExt];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const lang of languages) {
|
for (const lang of languages) {
|
||||||
if (lang === 'en') continue;
|
if (lang === 'en') continue;
|
||||||
|
|
||||||
const langDir = path.join(DIST_DIR, lang);
|
const langDir = path.join(DIST_DIR, lang);
|
||||||
if (!fs.existsSync(langDir)) {
|
|
||||||
fs.mkdirSync(langDir, { recursive: true });
|
processFileForLanguage(
|
||||||
|
originalContent,
|
||||||
|
file,
|
||||||
|
lang,
|
||||||
|
translations,
|
||||||
|
langDir
|
||||||
|
);
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
if (processed % 10 === 0 || processed === total) {
|
||||||
|
console.log(` Progress: ${processed}/${total} pages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonPath = path.join(LOCALES_DIR, `${lang}/common.json`);
|
|
||||||
const toolsPath = path.join(LOCALES_DIR, `${lang}/tools.json`);
|
|
||||||
|
|
||||||
const common = fs.existsSync(commonPath)
|
|
||||||
? JSON.parse(fs.readFileSync(commonPath, 'utf-8'))
|
|
||||||
: {};
|
|
||||||
const tools = fs.existsSync(toolsPath)
|
|
||||||
? JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const dom = new JSDOM(originalContent);
|
|
||||||
const document = dom.window.document;
|
|
||||||
|
|
||||||
document.documentElement.lang = lang;
|
|
||||||
|
|
||||||
let title = null;
|
|
||||||
let description = null;
|
|
||||||
|
|
||||||
if (tools[translationKey]) {
|
|
||||||
title =
|
|
||||||
tools[translationKey].pageTitle ||
|
|
||||||
(tools[translationKey].name
|
|
||||||
? `${tools[translationKey].name} - BentoPDF`
|
|
||||||
: null);
|
|
||||||
description = tools[translationKey].subtitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
document.title = title;
|
|
||||||
const metaTitle = document.querySelector('meta[property="og:title"]');
|
|
||||||
if (metaTitle) metaTitle.content = title;
|
|
||||||
const metaTwitterTitle = document.querySelector(
|
|
||||||
'meta[name="twitter:title"]'
|
|
||||||
);
|
|
||||||
if (metaTwitterTitle) metaTwitterTitle.content = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
const metaDesc = document.querySelector('meta[name="description"]');
|
|
||||||
if (metaDesc) metaDesc.content = description;
|
|
||||||
const metaOgDesc = document.querySelector(
|
|
||||||
'meta[property="og:description"]'
|
|
||||||
);
|
|
||||||
if (metaOgDesc) metaOgDesc.content = description;
|
|
||||||
const metaTwitterDesc = document.querySelector(
|
|
||||||
'meta[name="twitter:description"]'
|
|
||||||
);
|
|
||||||
if (metaTwitterDesc) metaTwitterDesc.content = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
document
|
|
||||||
.querySelectorAll('link[rel="alternate"][hreflang]')
|
|
||||||
.forEach((el) => el.remove());
|
|
||||||
|
|
||||||
const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt;
|
|
||||||
|
|
||||||
languages.forEach((l) => {
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'alternate';
|
|
||||||
link.hreflang = l;
|
|
||||||
link.href = buildUrl(l === 'en' ? '' : l, pagePath);
|
|
||||||
document.head.appendChild(link);
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultLink = document.createElement('link');
|
|
||||||
defaultLink.rel = 'alternate';
|
|
||||||
defaultLink.hreflang = 'x-default';
|
|
||||||
defaultLink.href = buildUrl('', pagePath);
|
|
||||||
document.head.appendChild(defaultLink);
|
|
||||||
|
|
||||||
let canonical = document.querySelector('link[rel="canonical"]');
|
|
||||||
if (!canonical) {
|
|
||||||
canonical = document.createElement('link');
|
|
||||||
canonical.rel = 'canonical';
|
|
||||||
document.head.appendChild(canonical);
|
|
||||||
}
|
|
||||||
canonical.href = buildUrl(lang, pagePath);
|
|
||||||
|
|
||||||
const links = document.querySelectorAll('a[href]');
|
|
||||||
links.forEach((link) => {
|
|
||||||
const href = link.getAttribute('href');
|
|
||||||
if (!href) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
href.startsWith('http') ||
|
|
||||||
href.startsWith('//') ||
|
|
||||||
href.startsWith('#') ||
|
|
||||||
href.startsWith('mailto:') ||
|
|
||||||
href.startsWith('tel:') ||
|
|
||||||
href.startsWith('javascript:')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (href.startsWith('/assets/') || href.includes('/assets/')) return;
|
|
||||||
|
|
||||||
const langPrefixRegex = new RegExp(
|
|
||||||
`^(${BASE_PATH})?/(${languages.join('|')})(/|$)`
|
|
||||||
);
|
|
||||||
if (langPrefixRegex.test(href)) return;
|
|
||||||
|
|
||||||
let newHref;
|
|
||||||
if (href.startsWith('/')) {
|
|
||||||
const pathWithoutBase = href.startsWith(BASE_PATH)
|
|
||||||
? href.slice(BASE_PATH.length)
|
|
||||||
: href;
|
|
||||||
newHref = `${BASE_PATH}/${lang}${pathWithoutBase}`;
|
|
||||||
} else {
|
|
||||||
newHref = `${BASE_PATH}/${lang}/${href}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
link.setAttribute('href', newHref);
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(langDir, file), dom.serialize());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dom = new JSDOM(originalContent);
|
updateEnglishFile(filePath, originalContent);
|
||||||
const document = dom.window.document;
|
|
||||||
|
|
||||||
document
|
|
||||||
.querySelectorAll('link[rel="alternate"][hreflang]')
|
|
||||||
.forEach((el) => el.remove());
|
|
||||||
|
|
||||||
const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt;
|
|
||||||
|
|
||||||
languages.forEach((l) => {
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'alternate';
|
|
||||||
link.hreflang = l;
|
|
||||||
link.href = buildUrl(l === 'en' ? '' : l, pagePath);
|
|
||||||
document.head.appendChild(link);
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultLink = document.createElement('link');
|
|
||||||
defaultLink.rel = 'alternate';
|
|
||||||
defaultLink.hreflang = 'x-default';
|
|
||||||
defaultLink.href = buildUrl('', pagePath);
|
|
||||||
document.head.appendChild(defaultLink);
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, dom.serialize());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ i18n pages generated successfully!');
|
console.log('✅ i18n pages generated successfully!');
|
||||||
|
|||||||
Reference in New Issue
Block a user