Files
bentopdf/scripts/generate-i18n-pages.mjs
T

581 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs';
import path from 'path';
import { JSDOM } from 'jsdom';
import { fileURLToPath } from 'url';
import { serializeJsonLd } from './json-ld.mjs';
import { ENGLISH_ONLY_PAGES, processPage } from './content-segments.mjs';
import { completeDocsLanguages } from './docs-languages.mjs';
import { createTranslationChecker } from './page-translation.mjs';
import { canonicalBasePath } from './indexing-policy.mjs';
import { applyServerTranslations } from './server-translations.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DIST_DIR = path.resolve(__dirname, '../dist');
const LOCALES_DIR = path.resolve(__dirname, '../public/locales');
const CONTENT_DIR = path.resolve(__dirname, '../translations');
const SITE_URL = (process.env.SITE_URL || 'https://www.bentopdf.com').replace(
/\/+$/,
''
);
const BASE_PATH = (process.env.BASE_URL || '/').replace(/\/$/, '');
const languages = fs.readdirSync(LOCALES_DIR).filter((file) => {
return fs.statSync(path.join(LOCALES_DIR, file)).isDirectory();
});
const OFFICIAL_DOCS_URL = 'https://www.bentopdf.com/docs';
const docsLanguages = completeDocsLanguages(
path.resolve(__dirname, '../docs'),
languages.filter((lang) => lang !== 'en'),
process.env.DOCS_LANGUAGES
).enabled;
const toCamelCase = (str) => {
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
};
const KEY_MAPPING = {
index: 'home',
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`);
const contentPath = path.join(CONTENT_DIR, lang, 'content.json');
translations[lang] = {
content: fs.existsSync(contentPath)
? JSON.parse(fs.readFileSync(contentPath, 'utf-8'))
: {},
common: fs.existsSync(commonPath)
? JSON.parse(fs.readFileSync(commonPath, 'utf-8'))
: {},
tools: fs.existsSync(toolsPath)
? JSON.parse(fs.readFileSync(toolsPath, 'utf-8'))
: {},
};
}
return translations;
}
function loadEnglishTools() {
const toolsPath = path.join(LOCALES_DIR, 'en/tools.json');
if (!fs.existsSync(toolsPath)) return {};
return JSON.parse(fs.readFileSync(toolsPath, 'utf-8'));
}
const ENGLISH_TOOLS = loadEnglishTools();
const contentTotals = {};
// TODO@ALAM: Let users build only a single language
function buildUrl(langPrefix, pagePath) {
const parts = [SITE_URL];
const canonicalBase = canonicalBasePath();
if (canonicalBase) parts.push(canonicalBase.replace(/^\//, ''));
if (langPrefix) parts.push(langPrefix);
if (pagePath) parts.push(pagePath.replace(/^\//, ''));
return parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL;
}
const ORGANIZATION_LD_MARKER = 'data-bentopdf-organization';
function injectOrganizationLd(document) {
if (document.querySelector(`script[${ORGANIZATION_LD_MARKER}]`)) return;
const existing = document.querySelectorAll(
'script[type="application/ld+json"]'
);
for (const node of existing) {
try {
const parsed = JSON.parse(node.textContent || '');
if (parsed && parsed['@type'] === 'Organization') return;
} catch {
continue;
}
}
const data = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'BentoPDF',
url: SITE_URL,
logo: `${SITE_URL}/images/favicon.svg`,
sameAs: [
'https://github.com/alam00000/bentopdf',
'https://x.com/BentoPDF',
'https://www.linkedin.com/company/bentopdf/',
'https://www.instagram.com/thebentopdf/',
],
};
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.setAttribute(ORGANIZATION_LD_MARKER, '');
script.textContent = serializeJsonLd(data);
document.body.appendChild(script);
}
const BREADCRUMB_MARKER = 'data-bentopdf-breadcrumb';
const BRAND_NAME = process.env.VITE_BRAND_NAME || 'BentoPDF';
function buildLocalHomeHref(lang) {
const langSegment = lang === 'en' ? '/' : `/${lang}`;
return `${BASE_PATH}${langSegment}`.replace(/\/+/g, '/');
}
function removeStaticBreadcrumbLd(document) {
for (const script of document.querySelectorAll(
`script[type="application/ld+json"]:not([${BREADCRUMB_MARKER}])`
)) {
try {
const parsed = JSON.parse(script.textContent || '');
if (parsed && parsed['@type'] === 'BreadcrumbList') script.remove();
} catch {
continue;
}
}
}
function injectToolBreadcrumb(
document,
lang,
toolName,
toolUrl,
label = 'Breadcrumb'
) {
const h1 = document.querySelector('h1[data-i18n^="tools:"]');
if (!h1) return;
if (document.querySelector(`[${BREADCRUMB_MARKER}]`)) return;
removeStaticBreadcrumbLd(document);
const homeUrl = buildUrl(lang === 'en' ? '' : lang, '');
const nav = document.createElement('nav');
nav.setAttribute('aria-label', label);
nav.setAttribute(BREADCRUMB_MARKER, '');
nav.setAttribute('data-no-i18n', '');
nav.className = 'text-sm text-gray-400 mb-4';
const homeLink = document.createElement('a');
homeLink.href = buildLocalHomeHref(lang);
homeLink.className = 'hover:text-indigo-300';
homeLink.textContent = BRAND_NAME;
const sep = document.createElement('span');
sep.setAttribute('aria-hidden', 'true');
sep.className = 'mx-2';
sep.textContent = '›';
const current = document.createElement('span');
current.className = 'text-gray-300';
current.setAttribute('aria-current', 'page');
current.textContent = toolName;
nav.appendChild(homeLink);
nav.appendChild(sep);
nav.appendChild(current);
h1.parentNode.insertBefore(nav, h1);
const ld = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: BRAND_NAME,
item: homeUrl,
},
{
'@type': 'ListItem',
position: 2,
name: toolName,
item: toolUrl,
},
],
};
const script = document.createElement('script');
script.setAttribute('type', 'application/ld+json');
script.setAttribute(BREADCRUMB_MARKER, '');
script.textContent = serializeJsonLd(ld);
document.body.appendChild(script);
}
function resolveToolName(translationKey, langTools) {
const langEntry = langTools && langTools[translationKey];
if (langEntry && langEntry.name) return langEntry.name;
const enEntry = ENGLISH_TOOLS[translationKey];
return enEntry && enEntry.name ? enEntry.name : null;
}
function translationKeyForFile(filenameNoExt) {
return KEY_MAPPING[filenameNoExt] || toCamelCase(filenameNoExt);
}
let isPageTranslated = null;
function translatedLangsForFile(filenameNoExt, translations) {
if (!isPageTranslated) {
const toolsByLang = {};
for (const lang of languages) {
toolsByLang[lang] = translations[lang] ? translations[lang].tools : {};
}
isPageTranslated = createTranslationChecker(
toolsByLang,
translationKeyForFile
);
}
return languages.filter((l) => isPageTranslated(filenameNoExt, l));
}
function processFileForLanguage(
originalContent,
file,
lang,
translations,
langDir,
translatedLangs
) {
const filenameNoExt = file.replace('.html', '');
const translationKey = translationKeyForFile(filenameNoExt);
const isTranslated = translatedLangs.includes(lang);
const { tools } = translations[lang];
const dom = new JSDOM(originalContent);
const document = dom.window.document;
document.documentElement.lang = lang;
document.documentElement.dir = ['ar', 'fa'].includes(lang) ? 'rtl' : 'ltr';
document.querySelector('script[data-language-redirect]')?.remove();
if (ENGLISH_ONLY_PAGES.has(filenameNoExt)) {
const englishContent = document.getElementById('app');
englishContent?.setAttribute('lang', 'en');
englishContent?.setAttribute('dir', 'ltr');
}
const metaContent = (selector) =>
document.querySelector(selector)?.getAttribute('content') ?? null;
const englishMeta = {
title: document.title,
description: metaContent('meta[name="description"]'),
};
applyServerTranslations(document, translations[lang]);
const contentStats = processPage(document, filenameNoExt, {
translate: (id) => translations[lang].content[id] || null,
});
contentTotals[lang] ??= { found: 0, translated: 0, invalid: 0 };
contentTotals[lang].found += contentStats.found;
contentTotals[lang].translated += contentStats.translated;
contentTotals[lang].invalid += contentStats.invalid.length;
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;
}
const titleTranslated = document.title !== englishMeta.title;
const descriptionTranslated =
metaContent('meta[name="description"]') !== englishMeta.description;
if (title && !titleTranslated) {
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 && !descriptionTranslated) {
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;
if (isTranslated) {
translatedLangs.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 englishUrl = buildUrl('', pagePath);
const localizedUrl = buildUrl(lang, pagePath);
const canonicalUrl = isTranslated ? localizedUrl : englishUrl;
let canonical = document.querySelector('link[rel="canonical"]');
if (!canonical) {
canonical = document.createElement('link');
canonical.rel = 'canonical';
document.head.appendChild(canonical);
}
canonical.href = canonicalUrl;
const ogUrl = document.querySelector('meta[property="og:url"]');
if (ogUrl) ogUrl.content = canonicalUrl;
const twitterUrl = document.querySelector('meta[name="twitter:url"]');
if (twitterUrl) twitterUrl.content = canonicalUrl;
injectOrganizationLd(document);
const localizedToolName = resolveToolName(translationKey, tools);
if (localizedToolName) {
injectToolBreadcrumb(
document,
lang,
localizedToolName,
localizedUrl,
translations[lang].common?.nav?.breadcrumb || 'Breadcrumb'
);
}
if (docsLanguages.includes(lang)) {
const localDocs = `${BASE_PATH}/docs`;
document.querySelectorAll('a[href]').forEach((link) => {
const href = link.getAttribute('href');
for (const docsUrl of [OFFICIAL_DOCS_URL, localDocs]) {
if (href === docsUrl || href?.startsWith(`${docsUrl}/`)) {
link.setAttribute(
'href',
href.replace(docsUrl, `${docsUrl}/${lang}`)
);
break;
}
}
});
}
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:') ||
href.startsWith('data:') ||
href.startsWith('vbscript:')
) {
return;
}
if (href.startsWith('/assets/') || href.includes('/assets/')) return;
const hrefWithoutBase =
BASE_PATH && href.startsWith(`${BASE_PATH}/`)
? href.slice(BASE_PATH.length)
: href;
if (/^\/(blog|docs)(\/|$)/.test(hrefWithoutBase)) {
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.replace(/^([^?#]*[^/?#])\/(?=$|[?#])/, '$1')
);
});
const result = dom.serialize();
dom.window.close();
fs.writeFileSync(path.join(langDir, file), result);
}
function updateEnglishFile(filePath, originalContent, translatedLangs) {
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;
const canonicalUrl = buildUrl('', pagePath);
translatedLangs.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 = canonicalUrl;
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 = canonicalUrl;
const ogUrl = document.querySelector('meta[property="og:url"]');
if (ogUrl) ogUrl.content = canonicalUrl;
const twitterUrl = document.querySelector('meta[name="twitter:url"]');
if (twitterUrl) twitterUrl.content = canonicalUrl;
injectOrganizationLd(document);
const enTranslationKey =
KEY_MAPPING[filenameNoExt] || toCamelCase(filenameNoExt);
const enToolName = resolveToolName(enTranslationKey, ENGLISH_TOOLS);
if (enToolName) {
injectToolBreadcrumb(document, 'en', enToolName, canonicalUrl);
}
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 translatedLangs = translatedLangsForFile(
file.replace('.html', ''),
translations
);
for (const lang of languages) {
if (lang === 'en') continue;
const langDir = path.join(DIST_DIR, lang);
processFileForLanguage(
originalContent,
file,
lang,
translations,
langDir,
translatedLangs
);
processed++;
if (processed % 10 === 0 || processed === total) {
console.log(` Progress: ${processed}/${total} pages`);
}
// Clean up JSDOM instances
await new Promise((resolve) => setImmediate(resolve));
}
updateEnglishFile(filePath, originalContent, translatedLangs);
}
const covered = Object.entries(contentTotals).filter(
([, totals]) => totals.translated > 0
);
if (covered.length > 0) {
console.log(
` Page content translated: ${covered
.map(
([lang, totals]) =>
`${lang} ${Math.round((totals.translated / totals.found) * 100)}%${totals.invalid ? ` (${totals.invalid} rejected)` : ''}`
)
.join(', ')}`
);
}
console.log('✅ i18n pages generated successfully!');
}
generateI18nPages().catch((err) => {
console.error('❌ i18n page generation failed:', err);
process.exit(1);
});