Files
ArtPlayer/scripts/documentation/translation.ts
T

274 lines
7.7 KiB
TypeScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { globSync } from 'glob'
import { atomicWrite, ownedPath, read, sha256 } from './files.ts'
import {
markdownSignature,
protectMarkdown,
splitTranslation,
} from './markdown.ts'
interface TranslationEntry {
source: string
target: string
relative: string
sourceHash: string
targetHash: string | null
translatedHash?: string
}
interface Draft {
schemaVersion: 1
status: 'incomplete' | 'failed' | 'complete' | 'applied'
entries: TranslationEntry[]
error?: string
}
const docs = 'packages/artplayer-vitepress/docs'
const drafts = 'refactor/.cache/translations'
export function translationPlan(root: string): TranslationEntry[] {
return [
'index.md',
...globSync('{advanced,component,start}/**/*.md', {
cwd: ownedPath(root, docs),
posix: true,
}).sort(),
].map((relative) => {
const source = `${docs}/${relative}`
const target = `${docs}/en/${relative}`
const destination = ownedPath(root, target)
return {
source,
target,
relative,
sourceHash: sha256(read(ownedPath(root, source))),
targetHash: fs.existsSync(destination) ? sha256(read(destination)) : null,
}
})
}
export async function createTranslationDraft(
root: string,
translate: (content: string, signal: AbortSignal) => Promise<string>,
concurrency = 3,
): Promise<string> {
assert(Number.isInteger(concurrency) && concurrency > 0 && concurrency <= 5)
const entries = translationPlan(root)
const cache = ownedPath(root, drafts)
fs.mkdirSync(cache, { recursive: true })
const directory = fs.mkdtempSync(path.join(cache, 'draft-'))
const manifest: Draft = { schemaVersion: 1, status: 'incomplete', entries }
const save = () =>
atomicWrite(
path.join(directory, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
)
save()
const controller = new AbortController()
let cursor = 0
let failure: unknown
async function worker(): Promise<void> {
try {
while (!controller.signal.aborted) {
const entry = entries[cursor++]
if (!entry)
return
const source = read(ownedPath(root, entry.source))
assert.equal(
sha256(source),
entry.sourceHash,
'Source changed before translation',
)
const protectedSource = protectMarkdown(source)
const chunks: string[] = []
for (const chunk of splitTranslation(protectedSource.masked)) {
controller.signal.throwIfAborted()
chunks.push(await translate(chunk, controller.signal))
}
controller.signal.throwIfAborted()
const translated = protectedSource.restore(chunks.join('\n\n'))
assert(translated.trim(), 'Empty translated document')
atomicWrite(ownedPath(directory, entry.relative), translated)
entry.translatedHash = sha256(translated)
save()
}
}
catch (error) {
if (!controller.signal.aborted)
failure = error
controller.abort(error)
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, entries.length) }, worker),
)
if (controller.signal.aborted) {
manifest.status = 'failed'
manifest.error
= failure instanceof Error ? failure.message : 'Translation failed'
save()
throw new Error(
`Translation draft failed; existing English files unchanged. Draft: ${directory}. ${manifest.error}`,
)
}
manifest.status = 'complete'
save()
return directory
}
function inspectDraft(root: string, directory: string, checkHash = true) {
const relative = path
.relative(fs.realpathSync(root), path.resolve(directory))
.split(path.sep)
.join('/')
assert(
relative.startsWith(`${drafts}/draft-`),
'Apply requires an owned translation draft',
)
const safeDirectory = ownedPath(root, relative)
const manifest = JSON.parse(
read(ownedPath(safeDirectory, 'manifest.json')),
) as Draft
assert(
manifest.schemaVersion === 1
&& manifest.status === 'complete'
&& Array.isArray(manifest.entries),
'Draft is not complete',
)
const current = translationPlan(root)
assert.equal(
manifest.entries.length,
current.length,
'Translation source set changed',
)
const changes = current.map((entry, index) => {
const saved = manifest.entries[index]
assert(
saved
&& saved.source === entry.source
&& saved.target === entry.target
&& saved.relative === entry.relative,
'Invalid draft file mapping',
)
assert.equal(
saved.sourceHash,
entry.sourceHash,
'Source changed since translation',
)
assert.equal(
saved.targetHash,
entry.targetHash,
'English document changed since translation',
)
const content = read(ownedPath(safeDirectory, entry.relative))
if (checkHash) {
assert.equal(
sha256(content),
saved.translatedHash,
'Draft content changed without validation',
)
}
assert.deepEqual(
markdownSignature(content),
markdownSignature(read(ownedPath(root, entry.source))),
'Draft changed protected Markdown',
)
const file = ownedPath(root, entry.target)
return {
...entry,
file,
content,
before: fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null,
}
})
return { safeDirectory, manifest, changes }
}
export function validateTranslationDraft(
root: string,
directory: string,
): void {
const { safeDirectory, manifest, changes } = inspectDraft(
root,
directory,
false,
)
changes.forEach((change, index) => {
const entry = manifest.entries[index]
assert(entry)
entry.translatedHash = sha256(change.content)
})
atomicWrite(
ownedPath(safeDirectory, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
)
}
export function applyTranslationDraft(
root: string,
directory: string,
write: typeof atomicWrite = atomicWrite,
): void {
const { safeDirectory, manifest, changes } = inspectDraft(root, directory)
const applied: typeof changes = []
try {
for (const change of changes) {
assert.equal(
sha256(read(ownedPath(root, change.source))),
change.sourceHash,
'Source changed during apply',
)
assert.equal(
read(ownedPath(safeDirectory, change.relative)),
change.content,
'Draft changed during apply',
)
assert.equal(ownedPath(root, change.target), change.file)
const currentHash = fs.existsSync(change.file)
? sha256(read(change.file))
: null
assert.equal(
currentHash,
change.targetHash,
'English document changed during apply',
)
write(change.file, change.content)
applied.push(change)
}
for (const change of changes) {
assert.equal(
sha256(read(ownedPath(root, change.source))),
change.sourceHash,
'Source changed during apply',
)
}
manifest.status = 'applied'
atomicWrite(
ownedPath(safeDirectory, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
)
}
catch (error) {
const failures: unknown[] = [error]
for (const change of applied.reverse()) {
try {
assert.equal(
sha256(read(change.file)),
sha256(change.content),
'Concurrent change prevents safe rollback',
)
if (change.before === null)
fs.unlinkSync(change.file)
else atomicWrite(change.file, change.before)
}
catch (failure) {
failures.push(failure)
}
}
throw new AggregateError(
failures,
'Draft apply failed; inspect errors and retained draft before retrying',
)
}
}