diff --git a/content/document.ts b/content/document.ts index b8a48e58d0ee..ed44212ffe1f 100644 --- a/content/document.ts +++ b/content/document.ts @@ -143,6 +143,9 @@ export function saveFile( throw new Error("newSlug can not contain the '#' character"); } + const folderPath = path.dirname(filePath); + fs.mkdirSync(folderPath, { recursive: true }); + const combined = `---\n${yaml.dump(saveMetadata)}---\n\n${rawBody.trim()}\n`; fs.writeFileSync(filePath, combined); } @@ -157,8 +160,6 @@ export function trimLineEndings(string) { export function createHTML(html: string, metadata, root = null) { const folderPath = getFolderPath(metadata, root); - fs.mkdirSync(folderPath, { recursive: true }); - saveFile(getHTMLPath(folderPath), trimLineEndings(html), metadata); return folderPath; } @@ -170,8 +171,6 @@ export function createMarkdown( ) { const folderPath = getFolderPath(metadata, root); - fs.mkdirSync(folderPath, { recursive: true }); - saveFile(getMarkdownPath(folderPath), trimLineEndings(md), metadata); return folderPath; } diff --git a/content/redirect.ts b/content/redirect.ts index 02fec571b58a..dda17bde676a 100644 --- a/content/redirect.ts +++ b/content/redirect.ts @@ -149,6 +149,17 @@ function errorOnDuplicated(pairs: Pairs) { } } +function fixRedirectsCase(oldPairs: Pairs, caseChangedTargets: string[]) { + const newTargets = new Map( + caseChangedTargets.map((p) => [p.toLowerCase(), p]) + ); + const newPairs = oldPairs.map(([from, to]): Pair => { + const target = newTargets.get(to.toLowerCase()) ?? to; + return [from, target]; + }); + return newPairs; +} + function removeConflictingOldRedirects(oldPairs: Pairs, updatePairs: Pairs) { if (oldPairs.length === 0) { return oldPairs; @@ -226,8 +237,19 @@ function loadLocaleAndAdd( pairs.push(...loadPairsFromFile(redirectsFilePath, locale, strict && !fix)); } - const cleanPairs = removeConflictingOldRedirects(pairs, updatePairs); - cleanPairs.push(...updatePairs); + const caseChangedTargets = []; + const newPairs = []; + for (const [from, to] of updatePairs) { + if (from.toLowerCase() === to.toLowerCase()) { + caseChangedTargets.push(to); + } else { + newPairs.push([from, to]); + } + } + + let cleanPairs = removeConflictingOldRedirects(pairs, newPairs); + cleanPairs = fixRedirectsCase(cleanPairs, caseChangedTargets); + cleanPairs.push(...newPairs); let simplifiedPairs = shortCuts(cleanPairs); if (fix) { diff --git a/tool/cli.ts b/tool/cli.ts index b40bff822345..5bdd389b5a10 100644 --- a/tool/cli.ts +++ b/tool/cli.ts @@ -142,7 +142,7 @@ interface GatherGitHistoryActionParameters extends ActionParameters { interface SyncTranslatedContentActionParameters extends ActionParameters { args: { - locale: string[]; + locales: string[]; }; options: { verbose: boolean; @@ -654,29 +654,31 @@ program "sync-translated-content", "Sync translated content (sync with en-US slugs) for a locale" ) - .argument("", "Locale", { + .argument("", "Locale", { default: [...VALID_LOCALES.keys()].filter((l) => l !== "en-us"), validator: [...VALID_LOCALES.keys()].filter((l) => l !== "en-us"), }) .action( tryOrExit( async ({ args, options }: SyncTranslatedContentActionParameters) => { - const { locale } = args; + const { locales } = args; const { verbose } = options; if (verbose) { log.setDefaultLevel(log.levels.DEBUG); } - for (const l of locale) { + for (const locale of locales) { const { movedDocs, conflictingDocs, orphanedDocs, redirectedDocs, + renamedDocs, totalDocs, - } = syncAllTranslatedContent(l); - console.log(chalk.green(`Syncing ${l}:`)); + } = syncAllTranslatedContent(locale); + console.log(chalk.green(`Syncing ${locale}:`)); console.log(chalk.green(`Total of ${totalDocs} documents`)); console.log(chalk.green(`Moved ${movedDocs} documents`)); + console.log(chalk.green(`Renamed ${renamedDocs} documents`)); console.log(chalk.green(`Conflicting ${conflictingDocs} documents.`)); console.log(chalk.green(`Orphaned ${orphanedDocs} documents.`)); console.log( diff --git a/tool/sync-translated-content.ts b/tool/sync-translated-content.ts index c9ed47565ee6..3290a6b4c2d4 100644 --- a/tool/sync-translated-content.ts +++ b/tool/sync-translated-content.ts @@ -15,6 +15,7 @@ import { Redirect, } from "../content/index.js"; import { + DEFAULT_LOCALE, HTML_FILENAME, MARKDOWN_FILENAME, VALID_LOCALES, @@ -25,7 +26,9 @@ import { DocFrontmatter } from "../libs/types/document.js"; const CONFLICTING = "conflicting"; const ORPHANED = "orphaned"; -export function syncAllTranslatedContent(locale) { +const DEFAULT_LOCALE_LC = DEFAULT_LOCALE.toLowerCase(); + +export function syncAllTranslatedContent(locale: string) { if (!CONTENT_TRANSLATED_ROOT) { throw new Error( "CONTENT_TRANSLATED_ROOT must be set to sync translated content!" @@ -43,30 +46,34 @@ export function syncAllTranslatedContent(locale) { .crawl(path.join(CONTENT_TRANSLATED_ROOT, locale)); const files = [...(api.sync() as any)]; const stats = { - movedDocs: 0, conflictingDocs: 0, + movedDocs: 0, orphanedDocs: 0, redirectedDocs: 0, + renamedDocs: 0, totalDocs: files.length, }; for (const f of files) { - const { moved, conflicting, redirect, orphaned, followed } = + const { conflicting, moved, followed, orphaned, redirect, renamed } = syncTranslatedContent(f, locale); - if (redirect) { - redirects.set(redirect[0], redirect[1]); + if (conflicting) { + stats.conflictingDocs += 1; } if (moved) { stats.movedDocs += 1; } - if (conflicting) { - stats.conflictingDocs += 1; + if (followed) { + stats.redirectedDocs += 1; } if (orphaned) { stats.orphanedDocs += 1; } - if (followed) { - stats.redirectedDocs += 1; + if (redirect) { + redirects.set(redirect[0], redirect[1]); + } + if (renamed) { + stats.renamedDocs += 1; } } @@ -77,26 +84,17 @@ export function syncAllTranslatedContent(locale) { return stats; } -function resolve(slug) { +function resolve(slug: string) { if (!slug) { return slug; } - const url = buildURL("en-us", slug); + const url = buildURL(DEFAULT_LOCALE_LC, slug); const resolved = Redirect.resolve(url); - if (url !== resolved) { - const doc = Document.read(Document.urlToFolderPath(resolved)); - if (!doc) { - return slug; - } - const resolvedSlug = doc.metadata.slug; - if (slug !== resolvedSlug) { - return resolvedSlug; - } - } - return slug; + const doc = Document.read(Document.urlToFolderPath(resolved)); + return doc?.metadata.slug ?? slug; } -function mdOrHtmlExists(filePath) { +function mdOrHtmlExists(filePath: string) { const dir = path.dirname(filePath); return ( fs.existsSync(path.join(dir, MARKDOWN_FILENAME)) || @@ -104,7 +102,7 @@ function mdOrHtmlExists(filePath) { ); } -export function syncTranslatedContent(inFilePath, locale) { +export function syncTranslatedContent(inFilePath: string, locale: string) { if (!CONTENT_TRANSLATED_ROOT) { throw new Error( "CONTENT_TRANSLATED_ROOT must be set to sync translated content!" @@ -113,15 +111,14 @@ export function syncTranslatedContent(inFilePath, locale) { const status = { redirect: null, conflicting: false, + followed: false, moved: false, orphaned: false, - followed: false, + renamed: false, }; const rawDoc = fs.readFileSync(inFilePath, "utf-8"); const fileName = path.basename(inFilePath); - const extension = path.extname(fileName); - const bareFileName = path.basename(inFilePath, extension); const { attributes: oldMetadata, body: rawBody } = fm(rawDoc); const resolvedSlug = resolve(oldMetadata.slug); const metadata = { @@ -135,7 +132,12 @@ export function syncTranslatedContent(inFilePath, locale) { ) { return status; } - status.moved = oldMetadata.slug.toLowerCase() !== metadata.slug.toLowerCase(); + // Any case-sensitive change is (at least) a rename. + status.renamed = oldMetadata.slug !== metadata.slug; + // Any case-insensitive change is a move. + status.moved = + status.renamed && + oldMetadata.slug.toLowerCase() !== metadata.slug.toLowerCase(); if (status.moved) { log.log( @@ -168,25 +170,11 @@ export function syncTranslatedContent(inFilePath, locale) { dehash(); let filePath = getFilePath(); - status.orphaned = - !fs.existsSync( - path.join( - CONTENT_ROOT, - "en-us", - slugToFolder(metadata.slug), - bareFileName + ".md" - ) - ) && - !fs.existsSync( - path.join( - CONTENT_ROOT, - "en-us", - slugToFolder(metadata.slug), - bareFileName + ".html" - ) - ); + status.orphaned = !mdOrHtmlExists( + path.join(CONTENT_ROOT, DEFAULT_LOCALE_LC, slugToFolder(metadata.slug)) + ); - if (!status.moved && !status.orphaned) { + if (!status.renamed && !status.orphaned) { return status; } @@ -200,11 +188,10 @@ export function syncTranslatedContent(inFilePath, locale) { log.log(`${inFilePath} → ${filePath}`); throw new Error(`file: ${filePath} already exists!`); } - } else if (mdOrHtmlExists(filePath)) { - `unrooting ${inFilePath} (conflicting translation)`; + } else if (status.moved && mdOrHtmlExists(filePath)) { + console.log(`unrooting ${inFilePath} (conflicting translation)`); metadata.slug = `${CONFLICTING}/${metadata.slug}`; status.conflicting = true; - status.moved = true; filePath = getFilePath(); if (mdOrHtmlExists(filePath)) { metadata.slug = `${metadata.slug}_${crypto @@ -226,27 +213,49 @@ export function syncTranslatedContent(inFilePath, locale) { oldMetadata.slug, metadata.slug ); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - execGit(["mv", inFilePath, filePath], { cwd: CONTENT_TRANSLATED_ROOT }); - metadata.original_slug = oldMetadata.slug; + if (status.moved) { + moveContent(path.dirname(inFilePath), path.dirname(filePath)); + metadata.original_slug = oldMetadata.slug; + } Document.saveFile(filePath, Document.trimLineEndings(rawBody), metadata); - try { - fs.rmdirSync(path.dirname(inFilePath)); - } catch (e: any) { - if (e.code !== "ENOTEMPTY") { - throw e; + + return status; +} + +// Move all regular files (excluding subdirectories) from one directory to another, +// and delete the source directory if it's empty. +function moveContent(inFileDir: string, outFileDir: string) { + const files = fs.readdirSync(inFileDir, { + encoding: "utf-8", + withFileTypes: true, + }); + fs.mkdirSync(outFileDir, { recursive: true }); + const regularFiles = files + .filter((file) => file.isFile()) + .map((file) => file.name); + for (const filename of regularFiles) { + const source = path.join(inFileDir, filename); + execGit(["mv", source, outFileDir], { cwd: CONTENT_TRANSLATED_ROOT }); + } + // assuming that the source directory is empty + if (files.length === regularFiles.length) { + try { + fs.rmdirSync(inFileDir); + } catch (e: any) { + if (e.code !== "ENOTEMPTY") { + throw e; + } } } - return status; } export function syncTranslatedContentForAllLocales() { let moved = 0; for (const locale of VALID_LOCALES.keys()) { - if (locale == "en-us") { + if (locale === DEFAULT_LOCALE_LC) { continue; } - const { movedDocs = 0 } = syncAllTranslatedContent(locale); + const { movedDocs } = syncAllTranslatedContent(locale); moved += movedDocs; } return moved;