blob: 7c6f8a74e93b5d9cd1142b07516a50ebcae21d54 [file]
// Translation state is an automation ledger. A contributor can edit just the
// target: Git supplies the source revisions acknowledged by that target edit.
// Only changes to the corresponding English/Chinese inputs permit regeneration.
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { execFileSync } = require('node:child_process');
const assert = require('node:assert/strict');
const marker = Symbol.for('doris.source-revision-policy');
const files = new Map(),
trees = new Map(),
roots = new Map(),
targetCommits = new Map(),
scopes = new Map(),
commitDates = new Map();
const normalize = value => value.replace(/\r\n/g, '\n');
const hash = value => createHash('sha256').update(normalize(value)).digest('hex');
const blob = value => {
const bytes = Buffer.from(value);
return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
};
const git = (root, args) =>
execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
});
const safe = file =>
typeof file === 'string' &&
!path.isAbsolute(file) &&
!/[\\\x00-\x1f\x7f]/.test(file) &&
file.split('/').every(p => p && p !== '.' && p !== '..');
function enabled(root) {
const file = path.join(root, 'translations/config.json');
return fs.existsSync(file) && JSON.parse(fs.readFileSync(file, 'utf8')).maintenancePolicy === 'source-revisions-v1';
}
function pathsFor(root, record) {
const file = path.join(root, `translations/scope/${record.locale}.json`);
if (!fs.existsSync(file)) return null;
const stat = fs.statSync(file, { bigint: true }),
stamp = String(stat.mtimeNs) + ':' + stat.size;
if (scopes.get(file)?.stamp !== stamp) scopes.set(file, { stamp, data: JSON.parse(fs.readFileSync(file, 'utf8')) });
const group = scopes
.get(file)
.data.groups.find(
g => g.pluginId === record.pluginId && g.version === record.version && (!g.kind || g.kind === 'blog'),
);
if (!group || !safe(record.sourcePath) || !safe(record.targetPath)) return null;
const prefix = `i18n/${record.locale}/`;
if (!record.targetPath.startsWith(prefix)) return null;
const chinese = 'i18n/zh-CN/' + record.targetPath.slice(prefix.length);
const suffix =
group.kind === 'blog'
? record.targetPath.split('/docusaurus-plugin-content-blog/')[1]
: record.targetPath.split(`/${group.version === 'current' ? 'current' : 'version-' + group.version}/`)[1];
if (!suffix || !safe(suffix)) return null;
return { en: group.sourceRoot + '/' + suffix, 'zh-CN': chinese };
}
function working(root, file) {
try {
assert(safe(file), 'Invalid translation input path');
let full = root,
stat;
for (const part of file.split('/')) {
full = path.join(full, part);
stat = fs.lstatSync(full, { bigint: true });
assert(!stat.isSymbolicLink(), 'Symlink in translation input');
}
assert(stat.isFile(), 'Translation input must be a regular file');
const stamp = `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
if (files.get(full)?.stamp !== stamp) {
const content = fs.readFileSync(full, 'utf8');
files.set(full, { stamp, hash: hash(content), blob: blob(content), lfBlob: blob(normalize(content)) });
}
return files.get(full);
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
function blobAt(root, ref, file) {
assert(safe(file), 'Invalid source revision path');
const dir = file.startsWith('i18n/') ? file.split('/').slice(0, 2).join('/') : file.split('/')[0];
const key = JSON.stringify([root, ref, dir]);
if (!roots.has(key)) {
const entry = git(root, ['ls-tree', '-z', ref, '--', dir]).split('\0').filter(Boolean)[0];
roots.set(key, entry ? entry.split('\t')[0].split(' ')[2] : null);
}
const tree = roots.get(key);
if (!tree) return null;
if (!trees.has(root + '\0' + tree)) {
const entries = new Map(
git(root, ['ls-tree', '-r', '-z', tree])
.split('\0')
.filter(Boolean)
.map(row => {
const tab = row.indexOf('\t');
return [row.slice(tab + 1), row.slice(0, tab).split(' ')[2]];
}),
);
trees.set(root + '\0' + tree, entries);
}
return trees.get(root + '\0' + tree).get(file.slice(dir.length + 1)) ?? null;
}
function snapshot(root, record, ref) {
const paths = pathsFor(root, record);
if (!paths) return null;
return Object.fromEntries(
Object.entries(paths).map(([locale, file]) => [locale, { path: file, blob: blobAt(root, ref, file) }]),
);
}
function currentSnapshot(root, record, ref) {
const paths = pathsFor(root, record);
const head = ref ?? git(root, ['rev-parse', 'HEAD']).trim();
return paths
? Object.fromEntries(
Object.entries(paths).map(([lang, file]) => {
const content = working(root, file),
committed = blobAt(root, head, file);
return [
lang,
{
path: file,
blob: content
? content.blob === committed || content.lfBlob === committed
? committed
: content.blob
: null,
},
];
}),
)
: null;
}
const info = record => record?.[marker];
function hashesAt(root, ref, paths) {
if (!paths.length) return new Map();
const result = execFileSync('git', ['cat-file', '--batch'], {
cwd: root,
input: paths.map(p => ref + ':' + p).join('\n') + '\n',
maxBuffer: 512 * 1024 * 1024,
});
let offset = 0;
const data = new Map();
for (const file of paths) {
const end = result.indexOf(10, offset);
assert(end >= 0, 'Truncated target snapshot');
const header = result.toString('utf8', offset, end);
offset = end + 1;
if (header.endsWith(' missing')) {
data.set(file, null);
continue;
}
const match = /^([a-f0-9]{40}) blob (\d+)$/.exec(header);
assert(match, 'Invalid target snapshot');
const size = Number(match[2]);
data.set(file, { blob: match[1], hash: hash(result.toString('utf8', offset, offset + size)) });
offset += size + 1;
}
return data;
}
function hydrate(root, locale, records, { ref = 'HEAD', worktree = true } = {}) {
if (!enabled(root)) return records;
const head = git(root, ['rev-parse', `${ref}^{commit}`]).trim();
const historical = worktree
? null
: hashesAt(
root,
head,
[...records.values()].filter(r => pathsFor(root, r)).map(r => r.targetPath),
);
const result = new Map();
for (const [id, raw] of records) {
const paths = pathsFor(root, raw);
if (!paths) {
result.set(id, raw);
continue;
}
let record = { ...raw },
control;
try {
const target = worktree ? working(root, raw.targetPath) : historical.get(raw.targetPath);
const committedBlob =
!worktree || target?.hash !== raw.targetHash
? worktree
? blobAt(root, head, raw.targetPath)
: (target?.blob ?? null)
: target.blob;
const targetHash = target?.hash ?? null;
let anchor = raw.sourceCommit,
manual = false,
preview = false;
if (targetHash && targetHash !== raw.targetHash) {
manual = true;
preview = worktree && target.blob !== committedBlob && target.lfBlob !== committedBlob;
const key = JSON.stringify([root, head, raw.targetPath]);
if (!preview && !targetCommits.has(key))
targetCommits.set(key, git(root, ['log', '-1', '--format=%H', head, '--', raw.targetPath]).trim());
anchor = preview ? head : targetCommits.get(key);
assert(/^[a-f0-9]{40}$/.test(anchor), 'Cannot establish the target revision');
const primary = git(root, ['show', `${anchor}:${raw.sourcePath}`]);
const dateKey = root + '\0' + anchor;
if (!commitDates.has(dateKey))
commitDates.set(dateKey, git(root, ['show', '-s', '--format=%cI', anchor]).trim());
record = {
...record,
sourceCommit: anchor,
sourceHash: hash(primary),
targetHash,
reviewStatus: 'manual',
translatedAt: commitDates.get(dateKey),
};
}
assert(/^[a-f0-9]{40}$/.test(anchor), 'Invalid source revision commit');
const baseline = snapshot(root, record, anchor);
if (!manual && raw.sourceRevisions)
assert.deepEqual(raw.sourceRevisions, baseline, 'Source revision ledger mismatch');
const current = worktree ? currentSnapshot(root, record, head) : snapshot(root, record, head);
const changed = Object.keys(paths).filter(lang => baseline[lang].blob !== current[lang].blob);
control = { head, anchor, manual, preview, baseline, current, changed };
} catch (error) {
control = { error: error.message, changed: [] };
}
Object.defineProperty(record, marker, { value: control, enumerable: true });
result.set(id, record);
}
return result;
}
function canGenerate(record) {
if (!record) return true;
const control = info(record);
return control ? !control.error && !control.preview && control.changed.length > 0 : record.ownership === 'machine';
}
function resolveRecord(root, page, record, technicalHash, { ref = 'HEAD', worktree = true } = {}) {
if (record || !enabled(root) || !pathsFor(root, page)) return record;
const target = worktree
? working(root, page.targetPath)
: hashesAt(root, ref, [page.targetPath]).get(page.targetPath);
if (!target) return undefined;
// A zero sentinel deliberately routes an unregistered committed body
// through the same Git-derived manual-revision path as an edited target.
const seed = {
...page,
sourceLocale: page.sourceLocale ?? 'en',
sourceCommit: git(root, ['rev-parse', `${ref}^{commit}`]).trim(),
sourceHash: '0'.repeat(64),
targetHash: '0'.repeat(64),
sourceTechnicalHash: technicalHash ?? '0'.repeat(64),
ownership: 'human',
reviewStatus: 'manual',
translatedAt: '1970-01-01T00:00:00Z',
glossaryVersion: 'manual',
};
return hydrate(root, page.locale, new Map([['body', seed]]), { ref, worktree }).get('body');
}
function referenceSources(root, record) {
const control = info(record);
if (!enabled(root) || control?.error) return [];
const paths = pathsFor(root, record);
if (!paths) return [];
const chinese = control?.current['zh-CN'] ?? { path: paths['zh-CN'], blob: working(root, paths['zh-CN'])?.blob };
return chinese.blob
? [{ locale: 'zh-CN', path: chinese.path, content: fs.readFileSync(path.join(root, chinese.path), 'utf8') }]
: [];
}
module.exports = {
enabled,
pathsFor,
snapshot,
currentSnapshot,
hydrate,
info,
canGenerate,
referenceSources,
resolveRecord,
blob,
};