diff --git a/Dockerfile b/Dockerfile index 0be095b..3e2fd09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ ENV COMPRESSION_MODE=$COMPRESSION_MODE ARG 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 diff --git a/package.json b/package.json index 0f16c73..629f06f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "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:gzip": "COMPRESSION_MODE=g npm run build", "build:brotli": "COMPRESSION_MODE=b npm run build", diff --git a/scripts/generate-i18n-pages.mjs b/scripts/generate-i18n-pages.mjs index 28be5cd..52da337 100644 --- a/scripts/generate-i18n-pages.mjs +++ b/scripts/generate-i18n-pages.mjs @@ -24,6 +24,24 @@ const KEY_MAPPING = { 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 function buildUrl(langPrefix, pagePath) { const parts = [SITE_URL]; @@ -33,179 +51,218 @@ function buildUrl(langPrefix, pagePath) { 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() { console.log('🌍 Generating i18n pages...'); console.log(` SITE_URL: ${SITE_URL}`); console.log(` BASE_PATH: ${BASE_PATH || '/'}`); + console.log(` Languages: ${languages.length} (${languages.join(', ')})`); if (!fs.existsSync(DIST_DIR)) { console.error('❌ dist directory not found. Please run build first.'); process.exit(1); } + console.log(' Loading translations...'); + const translations = loadAllTranslations(); + const htmlFiles = fs .readdirSync(DIST_DIR) .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) { const filePath = path.join(DIST_DIR, file); 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) { if (lang === 'en') continue; 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); - 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()); + updateEnglishFile(filePath, originalContent); } console.log('✅ i18n pages generated successfully!');