Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- **A lecture-adding sync no longer replaces the target's translated `_toc.yml` part captions with English** (#254): the sync mirrored the source TOC verbatim, and part captions are the one thing in that file an edition localises by hand, so every lecture-adding sync silently reverted them — lecture-python.zh-cn#202 replaced 14 hand-set Chinese captions and the published site served 19 English section captions for 17 days, because English captions are valid MyST, review mode filters `_toc.yml` out before any model call, and the strict build passes. The target TOC is now fetched alongside the source (on the sync path and on the rebase replay, which re-copies the TOC the same way) and its captions are carried forward in `src/toc-captions.ts`. Parts are matched by **shared file membership**, not identical file sets: the canonical trigger is a sync that adds one lecture to one part, so identity matching would lose the caption of exactly the part the sync touches — measured on the real python/zh-cn pair, appending one lecture to the first part reverted `基础工具` to `Tools and Techniques` under identity matching and keeps it under overlap matching. A part sharing no files with any target part is new or wholly rewritten and keeps the source caption with a warning naming it — the honest untranslated state, rather than a positional guess that could attach a stale translation to a different topic; a matched part whose caption is already byte-identical to the source is logged as not localised (the four never-localised programming editions), which is the signal the shared deterministic check in the #254 design will turn into a gate. The output is the source text with only the caption values substituted, verified by parse against the intended document, with a `noArrayIndent` re-serialisation as the fallback — QuantEcon TOCs use the zero-indent block style that a js-yaml round trip re-indents, which would have made every caption merge a 200-line rewrite that hides the real change and drops comments. The target fetch swallows only a genuine 404 (first delivery); any other status fails the fetch, since treating an outage as "no target TOC" is precisely the way the captions would be lost again. Interim step toward W1's structured TOC merge (#259), where captions are never taken from the source at all; this change does not close #254.

## [0.27.0] - 2026-09-01

### Added
Expand Down
164 changes: 160 additions & 4 deletions dist-action/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37028,6 +37028,142 @@ function stateFileRelativePath(filename) {
return `${TRANSLATE_DIR}/${STATE_DIR}/${filename}.yml`;
}

// dist/toc-captions.js
var CAPTION_LINE_RE = /^(\s*(?:-\s+)?caption:\s*)(.*?)(\s*)$/;
function partFiles(part) {
const out = /* @__PURE__ */ new Set();
const visit = (entries) => {
if (!Array.isArray(entries))
return;
for (const entry of entries) {
if (typeof entry !== "object" || entry === null)
continue;
const e = entry;
if (typeof e.file === "string")
out.add(e.file);
visit(e.chapters);
visit(e.sections);
}
};
visit(part.chapters);
return out;
}
function partsOf(doc) {
if (!doc || typeof doc !== "object")
return void 0;
const parts = doc.parts;
if (!Array.isArray(parts))
return void 0;
return parts.filter((p) => typeof p === "object" && p !== null);
}
function matchPartsByOverlap(source, target) {
const candidates = [];
source.forEach((s, i) => {
target.forEach((t, j) => {
let overlap = 0;
for (const f of s)
if (t.has(f))
overlap++;
if (overlap > 0)
candidates.push({ i, j, overlap });
});
});
candidates.sort((a, b) => b.overlap - a.overlap || Math.abs(a.i - a.j) - Math.abs(b.i - b.j) || a.i - b.i);
const matched = /* @__PURE__ */ new Map();
const claimed = /* @__PURE__ */ new Set();
for (const { i, j } of candidates) {
if (matched.has(i) || claimed.has(j))
continue;
matched.set(i, j);
claimed.add(j);
}
return matched;
}
function renderCaption(value) {
return dump(value, { lineWidth: -1 }).replace(/\n$/, "");
}
function substituteCaptionLines(sourceYaml, captions) {
const lines = sourceYaml.split("\n");
let k = -1;
for (let n = 0; n < lines.length; n++) {
if (lines[n].trimStart().startsWith("#"))
continue;
const m = CAPTION_LINE_RE.exec(lines[n]);
if (!m)
continue;
k++;
const wanted = captions.get(k);
if (wanted === void 0)
continue;
lines[n] = `${m[1]}${renderCaption(wanted)}${m[3]}`;
}
return lines.join("\n");
}
function sameDocument(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
function mergeTargetCaptions(sourceYaml, targetYaml, logger) {
let source;
let target;
try {
source = load(sourceYaml);
target = load(targetYaml);
} catch {
logger?.warning("Could not parse _toc.yml for caption merge \u2014 using source as-is");
return sourceYaml;
}
const sourceParts = partsOf(source);
const targetParts = partsOf(target);
if (!sourceParts || !targetParts || sourceParts.length === 0 || targetParts.length === 0) {
return sourceYaml;
}
const matched = matchPartsByOverlap(sourceParts.map(partFiles), targetParts.map(partFiles));
const captions = /* @__PURE__ */ new Map();
const notLocalised = [];
const unmatched = [];
sourceParts.forEach((part, i) => {
const sourceCaption = typeof part.caption === "string" ? part.caption : void 0;
const j = matched.get(i);
if (j === void 0) {
if (sourceCaption !== void 0)
unmatched.push(sourceCaption);
return;
}
const targetCaption = targetParts[j].caption;
if (typeof targetCaption !== "string" || targetCaption === "")
return;
if (targetCaption === sourceCaption) {
notLocalised.push(sourceCaption);
return;
}
captions.set(i, targetCaption);
});
for (const caption of unmatched) {
logger?.warning(`_toc.yml part "${caption}" has no counterpart in the target TOC \u2014 caption left as in source`);
}
if (notLocalised.length > 0) {
logger?.info(`_toc.yml: ${notLocalised.length} part caption(s) identical in source and target (not localised): ${notLocalised.map((c) => `"${c}"`).join(", ")}`);
}
if (captions.size === 0)
return sourceYaml;
const expected = load(sourceYaml);
const expectedParts = partsOf(expected);
for (const [i, caption] of captions)
expectedParts[i].caption = caption;
const substituted = substituteCaptionLines(sourceYaml, captions);
let verified = false;
try {
verified = sameDocument(load(substituted), expected);
} catch {
verified = false;
}
logger?.info(`Preserved ${captions.size} localised TOC part caption(s) from target`);
if (verified)
return substituted;
logger?.warning("_toc.yml caption merge could not be applied in place \u2014 re-serialising the document");
return dump(expected, { lineWidth: -1, noArrayIndent: true });
}

// dist/sync-orchestrator.js
async function loadGlossary(targetLanguage, builtInGlossaryDir, customGlossaryPath, logger) {
if (customGlossaryPath) {
Expand Down Expand Up @@ -37305,17 +37441,23 @@ var SyncOrchestrator = class {
}
}
/**
* Process a TOC file (copied directly without translation).
* Process a TOC file: the source is mirrored, with the target's localised
* part captions carried forward (#254; see `toc-captions.ts`). No target
* content means a first delivery, which takes the source verbatim.
*/
processTocFile(file, result) {
this.logger.info(`Processing TOC file ${file.filename}...`);
if (!file.newContent) {
throw new Error(`No content provided for ${file.filename}`);
}
let content = file.newContent;
if (file.targetContent) {
content = mergeTargetCaptions(file.newContent, file.targetContent, this.logger);
}
result.processedFiles.push(file.filename);
result.translatedFiles.push({
path: file.filename,
content: file.newContent,
content,
sha: file.existingFileSha
});
this.logger.info(`Successfully processed ${file.filename}`);
Expand Down Expand Up @@ -39510,15 +39652,22 @@ async function rebaseSinglePR(octokit, pr, metadata, inputs) {
continue;
}
let existingFileSha;
let targetContent;
try {
const result2 = await fetchFileContent(octokit, owner, repo, file.path);
existingFileSha = result2.sha;
} catch {
targetContent = result2.content;
} catch (error4) {
const status = error4?.status;
if (status !== 404) {
throw new Error(`Could not read ${file.path} from ${owner}/${repo} (status ${status ?? "unknown"}): ${error4}`);
}
}
filesToSync.push({
filename: file.path,
type: "toc",
newContent: newContent2,
targetContent,
existingFileSha,
isNewFile: !existingFileSha
});
Expand Down Expand Up @@ -39928,16 +40077,23 @@ async function fetchAllFileContents(octokit, classified, inputs, targetOwner, ta
try {
const { content: newContent } = await fetchFileContent(octokit, sourceOwner, sourceRepo, file.filename, sha);
let existingFileSha;
let targetContent;
try {
const result = await fetchFileContent(octokit, targetOwner, targetRepo, file.filename);
existingFileSha = result.sha;
} catch {
targetContent = result.content;
} catch (error4) {
const status = error4?.status;
if (status !== 404) {
throw new Error(`Could not read ${file.filename} from ${targetOwner}/${targetRepo} (status ${status ?? "unknown"}): ${error4}`);
}
core9.info(`${file.filename} does not exist in target repo - will create it`);
}
filesToSync.push({
filename: file.filename,
type: "toc",
newContent,
targetContent,
existingFileSha,
isNewFile: !existingFileSha
});
Expand Down
Loading
Loading