83 lines
5.5 KiB
JavaScript
83 lines
5.5 KiB
JavaScript
import path from 'node:path';
|
||
import { Converter } from 'opencc-js';
|
||
import { root, jsonFile, resource, pool, writeJSON } from './lib.mjs';
|
||
|
||
const toSimplified = Converter({from:'hk',to:'cn'});
|
||
const toTraditional = Converter({from:'cn',to:'hk'});
|
||
export const hasHan = s => /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(s);
|
||
const normalized = s => { const text=s.replace(/\s+/g,' ').trim();return /[\u3040-\u30ff\uac00-\ud7af]/.test(text)?text:toSimplified(text); };
|
||
const dictFile = path.join(root,'scripts','locales','en.json');
|
||
const overrides = {
|
||
'云服务器':'Cloud Server','产品':'Products','产品中心':'Products','关于我们':'About Us','公司简介':'Company Profile','公司介绍':'Company Profile','联系我们':'Contact Us',
|
||
'物理服务器':'Dedicated Server','独立服务器':'Dedicated Server','物理裸机':'Dedicated Server','裸金属服务器':'Bare Metal Server','负载均衡':'Load Balancer','高防CDN':'Anti-DDoS CDN',
|
||
'技术支持':'Support','技术文档':'Documentation','文档中心':'Documentation','帮助文档':'Documentation','帮助中心':'Help Center','公告与新闻':'News & Announcements','新闻资讯':'News','官方公告':'Announcements',
|
||
'CPU':'CPU','内存':'Memory','硬盘':'Storage','带宽':'Bandwidth','线路':'Network','价格':'Price','防护DDOS':'DDoS Protection','月付':'Monthly','月':'Month','地区':'Region','可选配置':'Available Configurations',
|
||
'搜索':'Search','全部':'All','上一页':'Previous','下一页':'Next','返回列表':'Back to List','查看详情':'View Details','更多':'More','暂无相关内容':'No content available.','未找到相关内容':'No results found.',
|
||
'首页':'Home','联系客服':'Contact Support','系统盘':'System Disk','数据盘':'Data Disk','公开起售价':'Starting Price','CPU(核)':'CPU (Cores)','选择语言':'Select Language','关闭':'Close',
|
||
};
|
||
let dictionary;
|
||
let saveQueue=Promise.resolve();
|
||
const save=()=>{saveQueue=saveQueue.then(()=>writeJSON(dictFile,dictionary));return saveQueue;};
|
||
async function getDictionary() { if(!dictionary) dictionary={...await jsonFile(dictFile,{}),...overrides}; return dictionary; }
|
||
|
||
export function textNodes($) {
|
||
const values=[];
|
||
function visit(node) {
|
||
if (node.type==='text') { if(hasHan(node.data)) values.push({node,value:node.data}); return; }
|
||
if (node.type==='comment' || ['script','style','code','pre','svg','noscript'].includes(node.name) || node.attribs?.['data-no-translate']!==undefined || /(?:language-option|language-name|language-code|current-lang-label)/.test(node.attribs?.class||'')) return;
|
||
for(const child of node.children||[]) visit(child);
|
||
}
|
||
visit($.root()[0]);
|
||
return values;
|
||
}
|
||
export async function prepareTranslations(documents) {
|
||
const dictionary=await getDictionary(); const keys=new Set();
|
||
for(const $ of documents) {
|
||
for(const item of textNodes($)) keys.add(normalized(item.value));
|
||
$('*[title], *[alt], *[placeholder], *[aria-label], meta[name="description"], meta[name="keywords"]').each((_,el)=> {
|
||
for(const attr of ['title','alt','placeholder','aria-label','content']) {const s=$(el).attr(attr);if(s&&hasHan(s))keys.add(normalized(s));}
|
||
});
|
||
}
|
||
const missing=[...keys].filter(s=>s && (!dictionary[s]||hasHan(dictionary[s])));
|
||
const batches=[];let batch=[];let length=0;
|
||
for(const key of missing) {if(length+key.length>1800&&batch.length){batches.push(batch);batch=[];length=0;}batch.push(key);length+=key.length+12;}
|
||
if(batch.length)batches.push(batch);
|
||
console.log(`Translations: ${keys.size} Chinese strings, ${missing.length} new, ${batches.length} batches`);
|
||
async function translateOne(value) {
|
||
const source=/[\u3040-\u30ff]/.test(value)?'ja':/[\uac00-\ud7af]/.test(value)?'ko':'auto';
|
||
const url='https://translate.googleapis.com/translate_a/single?client=gtx&sl='+source+'&tl=en&dt=t&q='+encodeURIComponent(value);
|
||
const r=await resource(url);if(r.status!==200)throw new Error(`Translation HTTP ${r.status}`);
|
||
const json=JSON.parse(r.body.toString());return json[0].map(part=>part[0]||'').join('');
|
||
}
|
||
let completed=0;
|
||
await pool(batches,3,async values=> {
|
||
const query=values.map((s,i)=>`ZX${String(i).padStart(4,'0')}: ${s}`).join('\n');
|
||
const translated=await translateOne(query);
|
||
const matches=[...translated.matchAll(/ZX\s*(\d{4})\s*[::]?\s*([\s\S]*?)(?=ZX\s*\d{4}\s*[::]?|$)/gi)];
|
||
for(let i=0;i<values.length;i++){
|
||
const found=matches.find(m=>Number(m[1])===i)?.[2]?.trim();
|
||
dictionary[values[i]]=found&&!hasHan(found)?found:await translateOne(values[i]);
|
||
if(hasHan(dictionary[values[i]]))throw new Error(`Untranslated text: ${values[i].slice(0,80)}`);
|
||
}
|
||
await save();
|
||
console.log(`Translations: ${++completed}/${batches.length} batches saved`);
|
||
});
|
||
await save();
|
||
}
|
||
export async function localize($,language) {
|
||
const dictionary=await getDictionary();
|
||
const convert=s=> {
|
||
if(!hasHan(s))return s;
|
||
if(language==='zh-cn')return toSimplified(s);
|
||
if(language==='zh-hk')return toTraditional(toSimplified(s));
|
||
const key=normalized(s);const translated=dictionary[key];
|
||
if(!translated)throw new Error(`Missing English translation: ${key.slice(0,100)}`);
|
||
return (s.match(/^\s*/)?.[0]||'')+translated+(s.match(/\s*$/)?.[0]||'');
|
||
};
|
||
for(const item of textNodes($))item.node.data=convert(item.value);
|
||
$('*[title], *[alt], *[placeholder], *[aria-label], meta[name="description"], meta[name="keywords"]').each((_,el)=>{
|
||
for(const attr of ['title','alt','placeholder','aria-label','content']){const s=$(el).attr(attr);if(s&&hasHan(s))$(el).attr(attr,convert(s));}
|
||
});
|
||
return $;
|
||
}
|