blob: 6ec686a8db771d1c91f8aeb0e776052dd96196b4 [file]
const fs = require('node:fs/promises');
const path = require('node:path');
const native = require('@docusaurus/plugin-content-docs');
const {readVersionsMetadata} = require('@docusaurus/plugin-content-docs/lib/versions/index.js');
const {processDocMetadata} = require('@docusaurus/plugin-content-docs/lib/docs.js');
const {Globby, getPluginI18nPath, readLastUpdateData, aliasedSitePath, resolveMarkdownLinkPathname} = require('@docusaurus/utils');
const {applyTrailingSlash} = require('@docusaurus/utils-common');
const {hash, relative, identity, loadConfig, managedLocales, loadRecords, sourceLocaleFor, decide} = require('./state.cjs');
const {mergeAlternates} = require('./alternates.cjs');
const {structure, alignHeadings} = require('./structure.cjs');
async function exists(file) { try { await fs.access(file); return true; } catch { return false; } }
async function read(file) { return await exists(file) ? fs.readFile(file, 'utf8') : undefined; }
const {localizedContext} = require('./native-context.cjs');
const {adaptDoc, docsId} = require('./content-manifest.cjs');
const {withContentRoutes} = require('./route-context.cjs');
async function metadata(context, options, versionMetadata, source, filePath, content) {
return processDocMetadata({context, options: {...options, showLastUpdateTime: false, showLastUpdateAuthor: false},
versionMetadata, env: process.env.NODE_ENV, tagsFile: undefined,
docFile: {source, content, filePath, contentPath: path.dirname(filePath)}});
}
// Keep the native plugin name, validation, route parser, links and sidebars.
// The adapter only redirects the localization directory of every managed locale
// to its generated view and adds translation state.
module.exports = async function translationDocsPlugin(context, options) {
// Managed locales come from the translation configuration, restricted to the
// locales this build serves. Without managed locales the native render path
// stays untouched, while English/Chinese identities still enter the registry.
const metadataCache = new Map();
const readMetadata = (ctx, opts, version, source, file, content) => {
const key = JSON.stringify([ctx.i18n.currentLocale, version.versionName, file, hash(content)]);
if (!metadataCache.has(key)) metadataCache.set(key, metadata(ctx, opts, version, source, file, content));
return metadataCache.get(key);
};
const managed = managedLocales(context.siteDir, context.i18n.locales);
const settings = loadConfig(context.siteDir).locales;
const records = Object.fromEntries(managed.map(locale => [locale, loadRecords(context.siteDir, locale)]));
// Source locales are always present; a managed locale that repeats one of
// them must not shadow it.
const contexts = Object.fromEntries([...new Set(['en', 'zh-CN', ...managed])].map(lang => [lang, localizedContext(context, lang)]));
const versions = Object.fromEntries(await Promise.all(Object.entries(contexts).map(async ([lang, ctx]) =>
[lang, await readVersionsMetadata({context: ctx, options})])));
// Match i18n/<locale>'s depth so valid project-relative MDX imports still
// reach the same @site files. Native plugin namespaces isolate instances.
const viewRoot = locale => path.join(context.siteDir, '.i18n-build', locale);
const viewPluginDir = locale => getPluginI18nPath({localizationDir: viewRoot(locale),
pluginName: 'docusaurus-plugin-content-docs', pluginId: options.id});
const originalPluginDir = locale => getPluginI18nPath({localizationDir: contexts[locale].localizationDir,
pluginName: 'docusaurus-plugin-content-docs', pluginId: options.id});
// The locale being built is the only one whose view has to exist on disk.
const activeLocale = managed.includes(context.i18n.currentLocale) ? context.i18n.currentLocale : undefined;
if (activeLocale) {
await fs.rm(viewPluginDir(activeLocale), {recursive: true, force: true});
await fs.mkdir(viewPluginDir(activeLocale), {recursive: true});
if (await exists(originalPluginDir(activeLocale))) await fs.cp(originalPluginDir(activeLocale), viewPluginDir(activeLocale), {recursive: true});
}
const pages = [];
const sourceEntries = [];
const reports = [];
const route = permalink => applyTrailingSlash(permalink, {trailingSlash: context.siteConfig.trailingSlash, baseUrl: context.baseUrl});
const versionInputs = await Promise.all(versions.en.map(async (englishVersion, index) => {
const filenames = (await Globby(options.include, {cwd: englishVersion.contentPath, ignore: options.exclude})).sort();
return {index, englishVersion, filenames, registered: new Set(filenames),
versionDir: englishVersion.versionName === 'current' ? 'current' : `version-${englishVersion.versionName}`};
}));
// Native source metadata also exists when all optional locales are disabled.
// It supplies real Chinese slugs and per-file fallback, independent of docs state.
for (const {index, englishVersion, filenames} of versionInputs) {
for (const filename of filenames) {
const englishFile = path.join(englishVersion.contentPath, filename);
const englishContent = await fs.readFile(englishFile, 'utf8');
const englishDoc = await readMetadata(contexts.en, options, englishVersion, filename, englishFile, englishContent);
if (englishDoc.draft) continue;
for (const locale of ['en', 'zh-CN'].filter(lang => context.i18n.locales.includes(lang))) {
const file = locale === 'en' ? englishFile : path.join(versions[locale][index].contentPathLocalized, filename);
const content = locale === 'en' ? englishContent : await read(file);
const doc = locale === 'en' ? englishDoc : await readMetadata(contexts[locale], options, versions[locale][index], filename,
content === undefined ? englishFile : file, content ?? englishContent);
if (doc.draft) continue;
if (doc.id !== englishDoc.id) {
reports.push({kind: 'source_identity_conflict', pluginId: options.id, version: englishVersion.versionName,
sourceLocale: locale, docId: doc.id, englishDocId: englishDoc.id, sourcePath: relative(context.siteDir, file)});
continue;
}
sourceEntries.push({contentId: docsId({pluginId: options.id, version: englishVersion.versionName, docId: englishDoc.id}),
kind: 'docs', renderIntegrated: true, pluginId: options.id, version: englishVersion.versionName, docId: englishDoc.id, siteLocale: locale,
contentLocale: content === undefined ? 'en' : locale, state: content === undefined ? 'untranslated' : 'source',
fallback: content === undefined, indexable: content !== undefined && !doc.unlisted && !versions[locale][index].noIndex,
route: route(doc.permalink), englishRoute: route(englishDoc.permalink),
englishPath: relative(context.siteDir, englishFile), sourcePath: relative(context.siteDir, content === undefined ? englishFile : file),
sourceHash: hash(content ?? englishContent), targetHash: null, updatePolicy: 'native-manual-docs'});
}
}
}
for (const locale of managed) {
for (const {index, englishVersion, versionDir, filenames, registered} of versionInputs) {
const targetDir = path.join(originalPluginDir(locale), versionDir);
const seenIds = new Set();
for (const filename of filenames) {
const englishFile = path.join(englishVersion.contentPath, filename);
const englishContent = await fs.readFile(englishFile, 'utf8');
const englishDoc = await readMetadata(contexts.en, options, englishVersion, filename, englishFile, englishContent);
seenIds.add(englishDoc.id);
const page = {pluginId: options.id, version: englishVersion.versionName, docId: englishDoc.id, locale};
const fallbackOnly = require('./docs-language-policy.cjs').fallbackOnly(options.id, englishVersion.versionName);
const requestedSourceLocale = fallbackOnly ? 'en' : sourceLocaleFor(settings[locale], page);
const chineseFile = path.join(versions['zh-CN'][index].contentPathLocalized, filename);
const chineseContent = await read(chineseFile);
const sourceLocale = requestedSourceLocale === 'zh-CN' && chineseContent !== undefined ? 'zh-CN' : 'en';
const sourceFile = sourceLocale === 'en' ? englishFile : chineseFile;
const source = sourceLocale === 'en' ? englishContent : chineseContent;
const targetFile = path.join(targetDir, filename);
const target = await read(targetFile);
Object.assign(page, {sourceLocale, sourcePath: relative(context.siteDir, sourceFile),
englishPath: relative(context.siteDir, englishFile), targetPath: relative(context.siteDir, targetFile)});
let record = records[locale].get(identity(page));
const maintainCase = context.siteConfig.markdown.anchors.maintainCase;
const sourceStructure = target !== undefined
? await structure(source, path.extname(filename), maintainCase).catch(() => null) : null;
record=require('./source-revisions.cjs').resolveRecord(context.siteDir,page,record,sourceStructure?.technicalHash);
let state = decide({page, source, target, record, technicalHash: sourceStructure?.technicalHash});
if (fallbackOnly) state = {...state, status: 'unsupported_translation_version', useTarget: false, indexable: false, contentLocale: 'en'};
if (requestedSourceLocale !== sourceLocale) {
state = {...state, status: 'source_missing', useTarget: false, indexable: false, contentLocale: 'en'};
reports.push({kind: 'source_divergence', ...page, requestedSourceLocale});
}
// Parse all counterparts with the framework, including custom IDs,
// number prefixes and localized slugs. Never invent a missing node.
const chineseDoc = await readMetadata(contexts['zh-CN'], options, versions['zh-CN'][index], filename,
chineseContent === undefined ? englishFile : chineseFile, chineseContent ?? englishContent);
const nativeSourceDoc = sourceLocale === 'en' ? englishDoc : chineseDoc;
let localizedContent;
if (state.useTarget) {
const targetDoc = await readMetadata(contexts[locale], options, versions[locale][index], filename, targetFile, target).catch(() => null);
if (!targetDoc || targetDoc.id !== englishDoc.id || targetDoc.slug !== englishDoc.slug || targetDoc.draft !== englishDoc.draft
|| targetDoc.unlisted !== englishDoc.unlisted) {
state = {...state, status: 'state_conflict', useTarget: false, indexable: false, contentLocale: 'en'};
} else {
try { localizedContent = await alignHeadings(sourceLocale === 'en' ? sourceStructure
: await structure(englishContent, path.extname(filename), maintainCase), target, path.extname(filename), maintainCase); }
catch { state = {...state, status: 'heading_conflict', useTarget: false, indexable: false, contentLocale: 'en'}; }
}
}
// A Chinese source can supply content only for an English registered
// node. Its routing metadata must still match that node.
if (sourceLocale === 'zh-CN' && (chineseDoc.id !== englishDoc.id || chineseDoc.slug !== englishDoc.slug
|| chineseDoc.draft !== englishDoc.draft)) {
state = {...state, status: 'source_route_conflict', useTarget: false, indexable: false, contentLocale: 'en'};
reports.push({kind: 'source_divergence', ...page, reason: 'source_route_conflict'});
}
const selectedContent = state.useTarget ? localizedContent : englishContent;
const localizedDoc = await readMetadata(contexts[locale], options, versions[locale][index], filename, targetFile, selectedContent);
const indexable = state.indexable && !localizedDoc.draft && !localizedDoc.unlisted && !versions[locale][index].noIndex;
state = {...state, indexable, draft: localizedDoc.draft, unlisted: localizedDoc.unlisted,
permalink: route(localizedDoc.permalink), englishPermalink: route(englishDoc.permalink),
sourcePermalink: route(nativeSourceDoc.draft ? englishDoc.permalink : nativeSourceDoc.permalink),
alternates: {en: route(englishDoc.permalink), ...(!chineseDoc.draft && {'zh-CN': route(chineseDoc.permalink)}),
...(indexable ? {[locale]: route(localizedDoc.permalink)} : {})}};
pages.push(state);
if (context.i18n.currentLocale === locale) {
const viewFile = path.join(viewPluginDir(locale), versionDir, filename);
if (state.useTarget) {
await fs.mkdir(path.dirname(viewFile), {recursive: true});
await fs.writeFile(viewFile, selectedContent);
} else {
// Omitting it uses the native per-file English fallback.
await fs.rm(viewFile, {force: true});
}
}
}
for (const target of await Globby(options.include, {cwd: targetDir, ignore: options.exclude})) {
if (!registered.has(target)) reports.push({kind: 'orphan_target', locale, pluginId: options.id,
version: englishVersion.versionName, targetPath: relative(context.siteDir, path.join(targetDir, target))});
}
for (const record of records[locale].values()) {
if (record.pluginId === options.id && record.version === englishVersion.versionName && !seenIds.has(record.docId)) {
reports.push({kind: 'source_deleted', ...record});
}
}
}
}
// A Chinese-only source describes the repository rather than one target
// locale, so it is reported once instead of once per managed locale.
for (const {index, englishVersion, registered} of versionInputs) {
for (const target of await Globby(options.include, {cwd: versions['zh-CN'][index].contentPathLocalized, ignore: options.exclude})) {
if (!registered.has(target)) reports.push({kind: 'unsupported_source_route', pluginId: options.id,
version: englishVersion.versionName, sourceLocale: 'zh-CN',
sourcePath: relative(context.siteDir, path.join(versions['zh-CN'][index].contentPathLocalized, target))});
}
}
mergeAlternates(pages);
const pluginContext = activeLocale ? {...context, localizationDir: viewRoot(activeLocale)} : context;
const plugin = await native.default(pluginContext, options);
const byId = new Map(pages.map(page => [identity(page), page]));
const sourceAliases = new Map();
const adaptedLoaders = new WeakSet();
let loaded = false;
return withContentRoutes({...plugin,
getPathsToWatch() {
return [...plugin.getPathsToWatch().filter(file => !managed.some(locale => file.startsWith(viewRoot(locale)))),
...managed.map(locale => `${originalPluginDir(locale)}/**/*`),
...versions['zh-CN'].map(version => `${version.contentPathLocalized}/**/*`),
path.join(context.siteDir, 'translations/**/*.json')];
},
async loadContent() {
// A dev-server content reload must re-evaluate hashes and rebuild
// the view, just like a new static build. Do not watch our own view.
if (loaded) return (await translationDocsPlugin(context, options)).loadContent();
loaded = true;
let content;
const dateOptions = {showLastUpdateTime: options.showLastUpdateTime,
showLastUpdateAuthor: options.showLastUpdateAuthor};
// Native dates for generated views would query untracked files.
// Every managed doc receives its actual source dates below. Keep
// the original options for contentLoaded and the displayed footer.
if (activeLocale) Object.assign(options, {showLastUpdateTime: false, showLastUpdateAuthor: false});
try {content = await plugin.loadContent();}
finally {if (activeLocale) Object.assign(options, dateOptions);}
if (activeLocale) {
for (const version of content.loadedVersions) {
for (const doc of [...version.docs, ...version.drafts]) {
const state = byId.get(identity({pluginId: options.id, version: version.versionName, docId: doc.id, locale: activeLocale}));
if (!state || state.permalink !== route(doc.permalink)) throw new Error(`Translation view/metadata mismatch: ${doc.id}`);
doc.translation = state;
// A translation commit is not a technical update. Ask the
// same parser/map/git machinery about the actual source.
const sourceFile = path.join(context.siteDir, state.contentLocale === 'en' ? state.englishPath : state.sourcePath);
const parsed = await context.siteConfig.markdown.parseFrontMatter({filePath: sourceFile,
fileContent: await fs.readFile(sourceFile, 'utf8'),
defaultParseFrontMatter: require('@docusaurus/utils').DEFAULT_PARSE_FRONT_MATTER});
// Even plugins that hide the date need the real source
// timestamp for sitemap lastmod when their file is in
// the temporary view (which has no Git history).
const sourceOptions = state.useTarget
? {...options, showLastUpdateTime: true} : options;
const lastUpdate = await readLastUpdateData(sourceFile, sourceOptions, parsed.frontMatter.last_update);
doc.lastUpdatedAt = lastUpdate.lastUpdatedAt;
doc.lastUpdatedBy = lastUpdate.lastUpdatedBy;
}
}
}
return {...content, contentIdentity: {entries: [...sourceEntries, ...pages.filter(page => !page.draft).map(adaptDoc)], reports},
...(managed.length && {translationManifest: {schemaVersion: 1, pluginId: options.id, locale: context.i18n.currentLocale, pages, reports}})};
},
translateContent({content, translationFiles}) {
// Docusaurus's native translateLoadedContent returns only versions.
return {...plugin.translateContent({content, translationFiles}), translationManifest: content.translationManifest, contentIdentity: content.contentIdentity};
},
async contentLoaded(args) {
await plugin.contentLoaded(args);
sourceAliases.clear();
if (activeLocale) {
for (const version of args.content.loadedVersions) {
for (const doc of version.docs) {
const state = doc.translation;
const viewPath = path.join(viewPluginDir(activeLocale), path.relative(originalPluginDir(activeLocale), path.join(context.siteDir, state.targetPath)));
for (const filename of [state.englishPath, state.sourcePath, state.targetPath, relative(context.siteDir, viewPath)]) {
sourceAliases.set(aliasedSitePath(path.join(context.siteDir, filename), context.siteDir), doc.permalink);
}
}
}
} else {
for (const version of args.content.loadedVersions) for (const doc of version.docs) {
const entry = sourceEntries.find(page => page.siteLocale === context.i18n.currentLocale
&& page.version === version.versionName && page.docId === doc.id);
if (!entry) continue;
const englishVersion = versions.en.find(item => item.versionName === version.versionName);
const localized = path.join(version.contentPathLocalized, path.relative(englishVersion.contentPath,
path.join(context.siteDir, entry.englishPath)));
for (const filename of [path.join(context.siteDir, entry.englishPath), path.join(context.siteDir, entry.sourcePath), localized])
sourceAliases.set(aliasedSitePath(filename, context.siteDir), doc.permalink);
}
}
},
configureWebpack(...args) {
const result = plugin.configureWebpack(...args);
const resolveLocale = activeLocale ?? (context.i18n.currentLocale === 'zh-CN' ? 'zh-CN' : undefined);
if (resolveLocale) {
// Native MDX cache keys cannot see a changed counterpart route
// or state file read through fs. Bind the localized filesystem
// cache to this exact content/route decision snapshot.
if (args[0]?.cache?.type === 'filesystem') result.cache = {...args[0].cache,
version: `${args[0].cache.version}:translations:${hash(JSON.stringify([sourceEntries, pages]))}`};
for (const rule of result.module.rules) {
for (const item of rule.use) {
if (adaptedLoaders.has(item.options)) continue;
adaptedLoaders.add(item.options);
const nativeResolve = item.options.resolveMarkdownLink;
// Native initialization captures the resolver in its
// processors. Recreate them with the adapted resolver.
delete item.options.processors;
item.options.resolveMarkdownLink = params => {
const localizedPath = candidate => activeLocale ? path.join(viewPluginDir(activeLocale),
candidate.versionName === 'current' ? 'current' : `version-${candidate.versionName}`) : candidate.contentPathLocalized;
const version = versions[resolveLocale].find(candidate => [candidate.contentPath, localizedPath(candidate)]
.some(dir => params.sourceFilePath.startsWith(dir + path.sep)));
return version && resolveMarkdownLinkPathname(params.linkPathname, {...params, siteDir: context.siteDir,
sourceToPermalink: sourceAliases, contentPaths: {...version, contentPathLocalized: localizedPath(version)}}) || nativeResolve(params);
};
}
}
}
return result;
},
}, context);
};
module.exports.validateOptions = native.validateOptions;
// Allows offline inventory tools to select these configured plugin instances.
module.exports.isTranslationDocsPlugin = true;