Skip to content

Commit 5ff26bf

Browse files
authored
fix(translate): fallback for empty translatedTitle in toggle _category_.json + tests (#160)
* fix(translate): add fallback for empty translatedTitle in toggle _category_.json and add tests When translatedTitle is empty or whitespace-only, fall back to the English source title (or "Untitled") so _category_.json label/customProps.title are never blank. Adds unit tests for the fallback and a locale-output regression test that asserts all translated _category_.json files have non-empty labels. Closes #158, closes #159 * fix(translate): align Untitled fallback capitalization with getTitle default * fix(api-server): document OPENAI_API_KEY, API_HOST/PORT, and API_KEY_ in .env.example Add the missing environment variable entries that three tests in docker-config.test.ts and docker-smoke-tests.test.ts were asserting: - OPENAI_API_KEY and OPENAI_MODEL (required for notion-translate scripts) - API_HOST and API_PORT (used by api-server/server.ts at startup) - API_KEY_* pattern documentation (used by ApiKeyAuth to load keys from env)
1 parent 737d928 commit 5ff26bf

File tree

4 files changed

+193
-2
lines changed

4 files changed

+193
-2
lines changed

.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ MAX_IMAGE_RETRIES=3
5151
# TEST_MODE=true
5252

5353
# OpenAI API Configuration
54+
# Required for translation features (notion-translate scripts)
55+
OPENAI_API_KEY=your_openai_api_key_here
56+
57+
# OpenAI model to use for translations
58+
# Default: gpt-5-mini
59+
# Valid values: any OpenAI-compatible model name
60+
OPENAI_MODEL=gpt-5-mini
61+
5462
# Optional: Use alternative OpenAI-compatible APIs (like Deepseek)
5563
# OPENAI_BASE_URL=https://api.deepseek.com
5664
# OPENAI_MODEL=deepseek-chat
65+
66+
# API Server Configuration
67+
# Host and port for the Notion Jobs API server
68+
# Default: API_HOST=localhost, API_PORT=3001
69+
API_HOST=localhost
70+
API_PORT=3001
71+
72+
# API Key Authentication
73+
# Set one or more API_KEY_* variables to enable authentication on protected endpoints.
74+
# When no API_KEY_* variables are set, the API server is fully open (no auth required).
75+
# Format: API_KEY_<name>=<value> (minimum 16 characters)
76+
# Example: API_KEY_GITHUB_ACTIONS=your-secret-key-min-16-chars
77+
# API_KEY_GITHUB_ACTIONS=your-secret-key-min-16-chars

scripts/notion-translate/index.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,6 +1392,110 @@ describe("notion-translate index", () => {
13921392
});
13931393
});
13941394

1395+
describe("toggle _category_.json fallback behavior", () => {
1396+
const mockConfig = {
1397+
language: "pt-BR",
1398+
notionLangCode: "Portuguese",
1399+
outputDir: "/test/output",
1400+
};
1401+
1402+
it("falls back to English title when translatedTitle is empty string", async () => {
1403+
const { saveTranslatedContentToDisk } = await import("./index");
1404+
1405+
const togglePage = createMockNotionPage({
1406+
id: "toggle-abc123",
1407+
title: "English Toggle Title",
1408+
elementType: "Toggle",
1409+
});
1410+
1411+
await saveTranslatedContentToDisk(togglePage, "", "", mockConfig);
1412+
1413+
const writeCall = mockWriteFile.mock.calls.find((call: string[]) =>
1414+
call[0].endsWith("_category_.json")
1415+
);
1416+
expect(writeCall).toBeDefined();
1417+
const written = JSON.parse(writeCall[1] as string);
1418+
expect(written.label).toBe("English Toggle Title");
1419+
expect(written.customProps.title).toBe("English Toggle Title");
1420+
});
1421+
1422+
it("falls back to English title when translatedTitle is whitespace-only", async () => {
1423+
const { saveTranslatedContentToDisk } = await import("./index");
1424+
1425+
const togglePage = createMockNotionPage({
1426+
id: "toggle-def456",
1427+
title: "English Toggle Title",
1428+
elementType: "Toggle",
1429+
});
1430+
1431+
await saveTranslatedContentToDisk(togglePage, "", " ", mockConfig);
1432+
1433+
const writeCall = mockWriteFile.mock.calls.find((call: string[]) =>
1434+
call[0].endsWith("_category_.json")
1435+
);
1436+
expect(writeCall).toBeDefined();
1437+
const written = JSON.parse(writeCall[1] as string);
1438+
expect(written.label).toBe("English Toggle Title");
1439+
expect(written.customProps.title).toBe("English Toggle Title");
1440+
});
1441+
1442+
it("uses translatedTitle when it is non-empty", async () => {
1443+
const { saveTranslatedContentToDisk } = await import("./index");
1444+
1445+
const togglePage = createMockNotionPage({
1446+
id: "toggle-ghi789",
1447+
title: "English Toggle Title",
1448+
elementType: "Toggle",
1449+
});
1450+
1451+
await saveTranslatedContentToDisk(
1452+
togglePage,
1453+
"",
1454+
"Título Traduzido",
1455+
mockConfig
1456+
);
1457+
1458+
const writeCall = mockWriteFile.mock.calls.find((call: string[]) =>
1459+
call[0].endsWith("_category_.json")
1460+
);
1461+
expect(writeCall).toBeDefined();
1462+
const written = JSON.parse(writeCall[1] as string);
1463+
expect(written.label).toBe("Título Traduzido");
1464+
expect(written.customProps.title).toBe("Título Traduzido");
1465+
});
1466+
});
1467+
1468+
describe("non-toggle page output regression", () => {
1469+
it("writes .mdx file and does not produce _category_.json for page-type content", async () => {
1470+
const { saveTranslatedContentToDisk } = await import("./index");
1471+
1472+
const regularPage = createMockNotionPage({
1473+
id: "page-regular123",
1474+
title: "Regular Page",
1475+
elementType: "Page",
1476+
});
1477+
1478+
const filePath = await saveTranslatedContentToDisk(
1479+
regularPage,
1480+
"# Translated Content",
1481+
"Translated Title",
1482+
{
1483+
language: "pt-BR",
1484+
notionLangCode: "Portuguese",
1485+
outputDir: "/test/output",
1486+
}
1487+
);
1488+
1489+
expect(filePath).toMatch(/\.md$/);
1490+
expect(filePath).not.toContain("_category_.json");
1491+
1492+
const categoryCall = mockWriteFile.mock.calls.find((call: string[]) =>
1493+
call[0].endsWith("_category_.json")
1494+
);
1495+
expect(categoryCall).toBeUndefined();
1496+
});
1497+
});
1498+
13951499
describe("missing parent relation handling", () => {
13961500
it("gracefully skips pages without Parent item relation and reports as non-critical failure", async () => {
13971501
// Create a page WITHOUT parent relation

scripts/notion-translate/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,9 @@ export async function saveTranslatedContentToDisk(
681681
await fs.mkdir(sectionPath, { recursive: true });
682682

683683
// Create _category_.json file
684+
const effectiveTitle = translatedTitle?.trim() || title || "untitled";
684685
const categoryContent = {
685-
label: translatedTitle,
686+
label: effectiveTitle,
686687
position:
687688
(
688689
englishPage.properties[NOTION_PROPERTIES.ORDER] as
@@ -695,7 +696,7 @@ export async function saveTranslatedContentToDisk(
695696
type: "generated-index",
696697
},
697698
customProps: {
698-
title: translatedTitle,
699+
title: effectiveTitle,
699700
},
700701
};
701702

scripts/verify-locale-output.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,71 @@ describe("Locale Output Verification", () => {
279279
).toBeLessThanOrEqual(maxAllowedDiff);
280280
});
281281

282+
it("has non-empty label in translated toggle _category_.json files", async () => {
283+
const locales = ["es", "pt"];
284+
for (const locale of locales) {
285+
const localeDocsDir = path.join(
286+
i18nDir,
287+
locale,
288+
"docusaurus-plugin-content-docs",
289+
"current"
290+
);
291+
let categoryFiles: string[];
292+
try {
293+
// Recursively find all _category_.json files under the locale docs dir
294+
const findCategoryFiles = async (dir: string): Promise<string[]> => {
295+
const results: string[] = [];
296+
let entries;
297+
try {
298+
entries = await fs.readdir(dir, { withFileTypes: true });
299+
} catch {
300+
return results;
301+
}
302+
for (const entry of entries) {
303+
const fullPath = path.join(dir, entry.name);
304+
if (entry.isDirectory()) {
305+
results.push(...(await findCategoryFiles(fullPath)));
306+
} else if (entry.name === "_category_.json") {
307+
results.push(fullPath);
308+
}
309+
}
310+
return results;
311+
};
312+
categoryFiles = await findCategoryFiles(localeDocsDir);
313+
} catch (error) {
314+
if (
315+
error instanceof Error &&
316+
"code" in error &&
317+
(error as NodeJS.ErrnoException).code === "ENOENT"
318+
) {
319+
console.log(
320+
`${locale} locale docs directory not found - content branch may not have toggle pages`
321+
);
322+
continue;
323+
}
324+
throw error;
325+
}
326+
327+
if (categoryFiles.length === 0) {
328+
console.log(
329+
`No _category_.json files found for locale ${locale} - may not have toggle pages`
330+
);
331+
continue;
332+
}
333+
334+
for (const filePath of categoryFiles) {
335+
const content = await fs.readFile(filePath, "utf8");
336+
const category = JSON.parse(content);
337+
expect(
338+
category.label,
339+
`_category_.json at ${filePath} has empty label`
340+
).toBeTruthy();
341+
expect(typeof category.label).toBe("string");
342+
expect(category.label.trim().length).toBeGreaterThan(0);
343+
}
344+
}
345+
});
346+
282347
it("does not have English locale directory (en/)", async () => {
283348
const enDir = path.join(i18nDir, "en");
284349

0 commit comments

Comments
 (0)