import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, extname, join, relative } from "pathe"; import { localeCodes, localeTargetPath } from "../core/i18n.ts"; import type { BlumeProject } from "../core/project-graph.ts"; import type { Diagnostic, PageRecord } from "../core/types.ts"; import { hashSource } from "./ledger.ts"; import type { TranslationLedger } from "./ledger.ts"; import { discoverTranslatableMeta, metaTargetPath } from "./meta.ts"; import type { TranslatableMeta } from "./meta.ts"; /** * Classification of every (source, target locale) pair: * - `missing` — no non-fallback page record for (translationKey, locale). * Existence is the graph record, never a disk probe, so a hand-authored * translation at a non-canonical name still counts as existing. * - `stale` — a record exists, but the ledger hash differs from the current * source hash (the source changed since its last translation). * - up-to-date — hashes match (counted, not itemized). * - untracked — a record exists with no ledger entry: a pre-existing human * translation. Adopted (stamped at the current hash), never overwritten; * only `--force` retranslates it. */ export type WorkStatus = "missing" | "stale"; export interface PageWorkItem { kind: "page"; /** Target locale code (configured casing). */ locale: string; /** Absolute path of the default-locale source file. */ sourcePath: string; /** POSIX root-relative source path — the ledger key. */ sourceRel: string; status: WorkStatus; /** Absolute canonical target path (`localeTargetPath`). */ targetPath: string; /** POSIX root-relative target path, for display. */ targetRel: string; } /** One meta file needing its title in one locale, inside a `MetaWorkItem`. */ export interface MetaWorkEntry { meta: TranslatableMeta; status: WorkStatus; /** Absolute path of the generated per-locale `meta.ts`. */ targetPath: string; } /** All of one locale's needed meta titles — a single agent call. */ export interface MetaWorkItem { kind: "meta"; entries: MetaWorkEntry[]; locale: string; } export type WorkItem = MetaWorkItem | PageWorkItem; /** A pre-existing translation with no ledger entry, to adopt (stamp) as-is. */ export interface UntrackedEntry { /** The current source hash to stamp. */ hash: string; kind: "meta" | "page"; locale: string; sourceRel: string; } export interface TranslateWorkList { diagnostics: Diagnostic[]; items: WorkItem[]; /** Every ledger key in the current universe (for pruning). */ knownSources: Set; /** The locales this run targets (configured casing, default excluded). */ targetLocales: string[]; untracked: UntrackedEntry[]; /** Count of (source, locale) pairs already translated and current. */ upToDate: number; } const PAGE_EXTENSIONS = new Set([".md", ".mdx"]); /** * The translatable page universe: filesystem-backed default-locale pages. * Remote/staged sources have no writable path; fallback records are padding; * a `.$.` shared file already materializes into every locale. */ const translatablePages = ( project: BlumeProject, i18n: NonNullable ): { page: PageRecord; contentRoot: string; ext: string; sourcePath: string; }[] => { const rootsByName = new Map( project.sources.flatMap((source) => source.staged || !source.contentRoot ? [] : [[source.name, source.contentRoot] as const] ) ); const seen = new Set(); const universe: { page: PageRecord; contentRoot: string; ext: string; sourcePath: string; }[] = []; for (const page of project.graph.pages) { if ( page.locale !== i18n.defaultLocale || page.fallback || !page.sourcePath || seen.has(page.sourcePath) || // Archived snapshots are frozen and carry their own translations — // they are never (re)translated. (`localeTargetPath` would also compute // `fr/v1.0/…` where the snapshot's translation lives at `v1.0/fr/…`.) page.version !== "" ) { continue; } const ext = extname(page.sourcePath); const base = page.sourcePath.slice(0, page.sourcePath.length - ext.length); const contentRoot = rootsByName.get(page.source.name); if (!PAGE_EXTENSIONS.has(ext) || base.endsWith(".$") || !contentRoot) { continue; } seen.add(page.sourcePath); universe.push({ contentRoot, ext, page, sourcePath: page.sourcePath }); } return universe; }; /** * Compute the run's work: which (source, locale) pairs are missing or stale, * which existing translations to adopt, and what's already up to date. * `force` promotes every pair to work; `locales` narrows the targets. */ export const computeWorkList = async ( project: BlumeProject, ledger: TranslationLedger, options: { force?: boolean; locales?: string[] } = {} ): Promise => { const { i18n } = project.config; if (!i18n) { return { diagnostics: [], items: [], knownSources: new Set(), targetLocales: [], untracked: [], upToDate: 0, }; } const targetLocales = localeCodes(i18n).filter( (code) => code !== i18n.defaultLocale && (options.locales === undefined || options.locales.includes(code)) ); const translated = new Set( project.graph.pages.flatMap((page) => page.fallback ? [] : [`${page.translationKey}${page.locale}`] ) ); const { root } = project.context; const knownSources = new Set(); const untracked: UntrackedEntry[] = []; const pageItems: PageWorkItem[] = []; let upToDate = 0; for (const { page, contentRoot, ext, sourcePath } of translatablePages( project, i18n )) { const sourceRel = relative(root, sourcePath); knownSources.add(sourceRel); // oxlint-disable-next-line no-await-in-loop -- one read per source file const hash = hashSource(await readFile(sourcePath, "utf-8")); const contentRel = relative(contentRoot, sourcePath); for (const locale of targetLocales) { const targetPath = join( contentRoot, localeTargetPath(contentRel, ext, locale, i18n) ); const item = (status: WorkStatus): PageWorkItem => ({ kind: "page", locale, sourcePath, sourceRel, status, targetPath, targetRel: relative(root, targetPath), }); const exists = translated.has(`${page.translationKey}${locale}`); const stamp = ledger.files[sourceRel]?.[locale]; if (!exists) { pageItems.push(item("missing")); } else if (stamp === undefined) { if (options.force) { pageItems.push(item("stale")); } else { untracked.push({ hash, kind: "page", locale, sourceRel }); } } else if (stamp !== hash || options.force) { pageItems.push(item("stale")); } else { upToDate += 1; } } } const meta = await discoverTranslatableMeta(project); const metaItems: MetaWorkItem[] = []; for (const locale of targetLocales) { const entries: MetaWorkEntry[] = []; for (const source of meta.metas) { knownSources.add(source.sourceRel); const targetDir = dirname(metaTargetPath(source, locale)); const exists = ["meta.ts", "meta.js", "meta.mjs"].some((name) => existsSync(join(targetDir, name)) ); const hash = hashSource(source.raw); const stamp = ledger.files[source.sourceRel]?.[locale]; const entry = (status: WorkStatus): MetaWorkEntry => ({ meta: source, status, targetPath: metaTargetPath(source, locale), }); if (!exists) { entries.push(entry("missing")); } else if (stamp === undefined) { if (options.force) { entries.push(entry("stale")); } else { untracked.push({ hash, kind: "meta", locale, sourceRel: source.sourceRel, }); } } else if (stamp !== hash || options.force) { entries.push(entry("stale")); } else { upToDate += 1; } } if (entries.length > 0) { metaItems.push({ entries, kind: "meta", locale }); } } pageItems.sort((a, b) => a.sourceRel === b.sourceRel ? a.locale.localeCompare(b.locale) : a.sourceRel.localeCompare(b.sourceRel) ); metaItems.sort((a, b) => a.locale.localeCompare(b.locale)); return { diagnostics: meta.diagnostics, items: [...pageItems, ...metaItems], knownSources, targetLocales, untracked, upToDate, }; };