diff --git a/src/client/imageFigureNumber.js b/src/client/imageFigureNumber.js index ee50aa658..81e09f006 100644 --- a/src/client/imageFigureNumber.js +++ b/src/client/imageFigureNumber.js @@ -2,28 +2,48 @@ * Client module to automatically add figure numbers and captions to images * based on their alt text in blog posts and docs. */ -export function onRouteDidUpdate({ location, previousLocation }) { - // Only run on blog and doc pages - if (!location.pathname.match(/\/blog\//) && !location.pathname.match(/\/docs\//)) { + +/** + * Derive the locale from the current pathname so the caption prefix is always + * consistent with the page the user navigated *to*, not with whatever stale + * value `document.documentElement.lang` happens to hold during the transition. + * + * @param {string} pathname + * @returns {"zh" | "en"} + */ +function localeFromPathname(pathname) { + return pathname.startsWith("/zh/") || pathname === "/zh" ? "zh" : "en"; +} + +export function onRouteDidUpdate({ location }) { + // Only run on blog and doc pages (including root doc paths like /docs or /zh/docs) + if (!/(?:^|\/)(?:blog|docs)(?:$|\/)/.test(location.pathname)) { return; } - // Wait for DOM to be ready with multiple attempts - setTimeout(() => addFigureNumbers(), 100); - setTimeout(() => addFigureNumbers(), 500); - setTimeout(() => addFigureNumbers(), 1000); + const locale = localeFromPathname(location.pathname); + + // A single rAF tick is sufficient: Docusaurus has already committed the new + // page content to the DOM by the time onRouteDidUpdate fires. Using rAF + // instead of multiple staggered setTimeouts avoids duplicate caption + // injections when routes are navigated quickly. + requestAnimationFrame(() => addFigureNumbers(locale)); } -function addFigureNumbers() { +/** + * @param {"zh" | "en"} locale + */ +function addFigureNumbers(locale) { // Find all images in markdown content const articleContent = document.querySelector("article"); if (!articleContent) { return; } - // Find all images that are not in header or footer + const prefix = locale === "zh" ? "图" : "Figure"; + + // Find all images that are not logos or small icons const images = Array.from(articleContent.querySelectorAll("img")).filter((img) => { - // Filter out logos, avatars, icons const parentClass = img.parentElement?.className || ""; const isLogo = parentClass.includes("logo") || img.alt.includes("logo"); const isIcon = img.width < 100 || img.height < 100; @@ -33,57 +53,34 @@ function addFigureNumbers() { let figureCount = 0; images.forEach((img) => { - // Get alt text const altText = img.getAttribute("alt") || ""; if (!altText.trim()) { return; } - // Check if already has figcaption with figure number - const existingFigure = img.closest("figure"); - if (existingFigure) { - const existingFigcaption = existingFigure.querySelector("figcaption"); - if (existingFigcaption && existingFigcaption.textContent.match(/^(图|Figure)\d+:/)) { - figureCount++; - return; - } - } - - // Increment counter figureCount++; - // Create figure wrapper if it doesn't exist + // Wrap in
if not already wrapped. If the image is inside an anchor link, + // move the anchor element into the figure to preserve clickable links. let figure = img.closest("figure"); if (!figure) { figure = document.createElement("figure"); - img.parentNode.insertBefore(figure, img); - figure.appendChild(img); + const targetElement = + img.parentElement && img.parentElement.tagName === "A" ? img.parentElement : img; + targetElement.parentNode.insertBefore(figure, targetElement); + figure.appendChild(targetElement); } - // Remove existing figcaption if any (but only if it doesn't have figure number) - const existingFigcaption = figure.querySelector("figcaption"); - if (existingFigcaption && !existingFigcaption.textContent.match(/^(图|Figure)\d+:/)) { - existingFigcaption.remove(); - } else if (existingFigcaption) { - // Update existing figcaption with figure number - const currentLang = document.documentElement.lang || "en"; - const prefix = currentLang.startsWith("zh") ? "图" : "Figure"; - existingFigcaption.textContent = `${prefix}${figureCount}: ${altText}`; - return; + // Find or create the
, always overwriting its text so that + // navigating between locales corrects stale captions from a previous run. + let figcaption = figure.querySelector("figcaption"); + if (!figcaption) { + figcaption = document.createElement("figcaption"); + figure.appendChild(figcaption); } - - // Create figcaption with number and alt text - const figcaption = document.createElement("figcaption"); - const currentLang = document.documentElement.lang || "en"; - - // Use appropriate prefix based on language - const prefix = currentLang.startsWith("zh") ? "图" : "Figure"; figcaption.textContent = `${prefix}${figureCount}: ${altText}`; - // Append figcaption to figure - figure.appendChild(figcaption); - - // Add styling class + // Apply styles (idempotent — repeated assignment is harmless) figure.style.cssText = ` margin: 2em 0; text-align: center; diff --git a/src/plugins/changelog/index.js b/src/plugins/changelog/index.js index c5a6b6091..2bbd23e4a 100644 --- a/src/plugins/changelog/index.js +++ b/src/plugins/changelog/index.js @@ -13,17 +13,18 @@ import {aliasedSitePath, docuHash, normalizeUrl} from '@docusaurus/utils'; /** * Multiple versions may be published on the same day, causing the order to be * the reverse. Therefore, our publish time has a "fake hour" to order them. + * + * NOTE: Both sets are passed in from loadContent() so that each build pass + * (en, zh, …) starts with a clean slate and locale runs cannot interfere with + * each other's timestamps or author maps. */ -const publishTimes = new Set(); -/** - * @type {Record} - */ -const authorsMap = {}; /** * @param {string} section + * @param {Set} publishTimes per-invocation dedup set + * @param {Record} authorsMap per-invocation authors accumulator */ -function processSection(section) { +function processSection(section, publishTimes, authorsMap) { const title = section .match(/\n## .*/)?.[0] .trim() @@ -94,7 +95,7 @@ function processSection(section) { publishTimes.add(`${date}T${hour}:00`); return { - title: title.replace(/ \(.*\)/, ''), + title: title.replace(/ \(.*\)/, ""), content: `--- mdx: format: md @@ -138,10 +139,16 @@ export default async function ChangelogPlugin(context, options) { ...blogPlugin, name: 'changelog-plugin', async loadContent() { + // Create fresh state per loadContent() call so that sequential locale + // build passes (en → zh) cannot see each other's timestamps or authors. + const publishTimes = new Set(); + /** @type {Record} */ + const authorsMap = {}; + const fileContent = (await fs.readFile(changelogPath, 'utf-8')).replace(/\r\n/g, '\n'); const sections = fileContent .split(/(?=\n## )/) - .map(processSection) + .map((section) => processSection(section, publishTimes, authorsMap)) .filter(Boolean); await Promise.all( sections.map((section) =>