From 73df21be2b3cc505a3ef8f909ff205b3e8fb2d0b Mon Sep 17 00:00:00 2001 From: Nader Jaber Date: Sun, 3 May 2026 14:02:08 +0300 Subject: [PATCH 1/7] fix: resolve trailing-slash routes to index.md to avoid intro.md collision Both the build-time writer and the client dropdown synthesized the markdown filename for any URL ending with `/` as the literal `intro.md`. A site with both `docs/intro.md` (URL `/docs/intro`) and a doc using `slug: '/'` (URL `/docs/`) would resolve both routes to the same `build/docs/intro.md` output path, with the second write silently overwriting the first. Extract a shared `getMarkdownUrl(routePath)` helper that returns `index.md` for trailing-slash routes, used by both `postBuild` and `MarkdownActionsDropdown`. `index.md` doesn't collide with any normal doc filename. Add `lib/` to the published `files` list and add a `node --test` script for test coverage. --- components/MarkdownActionsDropdown/index.js | 6 ++---- index.js | 5 ++--- lib/markdown-path.js | 7 +++++++ package.json | 4 ++++ test/markdown-path.test.js | 14 ++++++++++++++ 5 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 lib/markdown-path.js create mode 100644 test/markdown-path.test.js diff --git a/components/MarkdownActionsDropdown/index.js b/components/MarkdownActionsDropdown/index.js index b101a18..7d3c40a 100644 --- a/components/MarkdownActionsDropdown/index.js +++ b/components/MarkdownActionsDropdown/index.js @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; +import { getMarkdownUrl } from '../../lib/markdown-path'; export default function MarkdownActionsDropdown() { const [copied, setCopied] = useState(false); @@ -30,10 +31,7 @@ export default function MarkdownActionsDropdown() { }; }, [isOpen]); - // Construct the .md URL (handles directory indexes like /docs/ -> /docs/intro.md) - const markdownUrl = currentPath.endsWith('/') - ? `${currentPath}intro.md` - : `${currentPath}.md`; + const markdownUrl = getMarkdownUrl(currentPath); // Handle opening markdown in new tab const handleOpenMarkdown = () => { diff --git a/index.js b/index.js index 5f226bb..f731cc1 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ const fs = require('fs-extra'); const path = require('path'); +const { getMarkdownUrl } = require('./lib/markdown-path'); /** * Docusaurus plugin to copy raw markdown files to build output @@ -177,9 +178,7 @@ module.exports = function markdownSourcePlugin(context, options = {}) { : route.path.replace(/[^/]+$/, ''); // Construct the fetch URL the client dropdown will request - const fetchUrl = route.path.endsWith('/') - ? route.path + 'intro.md' - : route.path + '.md'; + const fetchUrl = getMarkdownUrl(route.path); // Strip baseUrl to get build-relative path const buildRelPath = stripBaseUrl(fetchUrl, baseUrl); diff --git a/lib/markdown-path.js b/lib/markdown-path.js new file mode 100644 index 0000000..939b952 --- /dev/null +++ b/lib/markdown-path.js @@ -0,0 +1,7 @@ +function getMarkdownUrl(routePath) { + return routePath.endsWith('/') + ? routePath + 'index.md' + : routePath + '.md'; +} + +module.exports = { getMarkdownUrl }; diff --git a/package.json b/package.json index 526a5f3..b5e3b20 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "2.2.4", "description": "A lightweight Docusaurus plugin that exposes your markdown files as raw .md URLs, perfect for LLMs and documentation tools", "main": "index.js", + "scripts": { + "test": "node --test test/*.test.js" + }, "keywords": [ "docusaurus", "docusaurus-plugin", @@ -38,6 +41,7 @@ }, "files": [ "index.js", + "lib/", "theme/", "components/", "README.md", diff --git a/test/markdown-path.test.js b/test/markdown-path.test.js new file mode 100644 index 0000000..6379e61 --- /dev/null +++ b/test/markdown-path.test.js @@ -0,0 +1,14 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { getMarkdownUrl } = require('../lib/markdown-path'); + +test('non-trailing-slash routes append .md to the path', () => { + assert.equal(getMarkdownUrl('/docs/intro'), '/docs/intro.md'); + assert.equal(getMarkdownUrl('/docs/guides/foo'), '/docs/guides/foo.md'); +}); + +test('trailing-slash routes resolve to index.md (avoids intro.md collision)', () => { + assert.equal(getMarkdownUrl('/docs/'), '/docs/index.md'); + assert.equal(getMarkdownUrl('/docs/guides/'), '/docs/guides/index.md'); + assert.equal(getMarkdownUrl('/'), '/index.md'); +}); From 77a51e8600ead9333b77572b4a350f5b368822d4 Mon Sep 17 00:00:00 2001 From: Nader Jaber Date: Sun, 3 May 2026 14:09:35 +0300 Subject: [PATCH 2/7] fix: protect fenced code blocks from MDX/Docusaurus transforms cleanMarkdownForDisplay's transforms ran globally over raw content with no awareness of fenced code blocks. Any docs page demonstrating Docusaurus or MDX syntax inside a fenced block had its examples silently mangled in the served .md: import lines stripped, blocks replaced with `**Label:**` blocks, custom components removed, and so on. Add a fence-aware transformOutsideCodeFences helper that walks lines, identifies backtick or tilde fences (length >= 3, up to 3 leading spaces, length-N closer must be >= length-N opener, supports CRLF and unclosed fences) and replaces fence blocks with placeholders before running the transform pipeline once on the protected content. The placeholder approach also correctly handles legitimate Docusaurus patterns like blocks that themselves contain fenced code. Extract cleanMarkdownForDisplay (and its convertTabs/convertDetails helpers) into lib/clean-markdown.js for testability. index.js now just imports it. Front-matter strip and leading-blank-line strip stay outside the fence guard since they only touch document boundaries. --- index.js | 106 +---------------------------------- lib/clean-markdown.js | 105 ++++++++++++++++++++++++++++++++++ lib/fence-transform.js | 52 +++++++++++++++++ test/clean-markdown.test.js | 55 ++++++++++++++++++ test/fence-transform.test.js | 65 +++++++++++++++++++++ 5 files changed, 278 insertions(+), 105 deletions(-) create mode 100644 lib/clean-markdown.js create mode 100644 lib/fence-transform.js create mode 100644 test/clean-markdown.test.js create mode 100644 test/fence-transform.test.js diff --git a/index.js b/index.js index f731cc1..b892f94 100644 --- a/index.js +++ b/index.js @@ -1,56 +1,13 @@ const fs = require('fs-extra'); const path = require('path'); const { getMarkdownUrl } = require('./lib/markdown-path'); +const { cleanMarkdownForDisplay } = require('./lib/clean-markdown'); /** * Docusaurus plugin to copy raw markdown files to build output * This allows users to view markdown source by appending .md to URLs */ -// Convert Tabs/TabItem components to readable markdown format -function convertTabsToMarkdown(content) { - const tabsPattern = /]*>([\s\S]*?)<\/Tabs>/g; - - return content.replace(tabsPattern, (fullMatch, tabsContent) => { - const tabItemPattern = /]*value="([^"]*)"[^>]*label="([^"]*)"[^>]*>([\s\S]*?)<\/TabItem>/g; - - let result = []; - let match; - - while ((match = tabItemPattern.exec(tabsContent)) !== null) { - const [, value, label, itemContent] = match; - - // Clean up indentation from the tab content - const cleanContent = itemContent - .split('\n') - .map(line => line.replace(/^\s{4}/, '')) // Remove 4-space indentation - .join('\n') - .trim(); - - result.push(`**${label}:**\n\n${cleanContent}`); - } - - return result.join('\n\n---\n\n'); - }); -} - -// Convert details/summary components to readable markdown format -function convertDetailsToMarkdown(content) { - const detailsPattern = /
\s*()?([^<]+)(<\/strong>)?<\/summary>([\s\S]*?)<\/details>/g; - - return content.replace(detailsPattern, (fullMatch, strongOpen, summaryText, strongClose, detailsContent) => { - // Clean up the details content - const cleanContent = detailsContent - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .join('\n') - .trim(); - - return `### ${summaryText.trim()}\n\n${cleanContent}`; - }); -} - // Flatten nested Docusaurus route tree into a flat array function flattenRoutes(routes) { return routes.flatMap(route => [ @@ -67,67 +24,6 @@ function stripBaseUrl(urlPath, baseUrl) { return urlPath.startsWith('/') ? urlPath.slice(1) : urlPath; } -// Clean markdown content for raw display - remove MDX/Docusaurus-specific syntax -function cleanMarkdownForDisplay(content, routeDir) { - - // 1. Strip YAML front matter (--- at start, content, then ---) - content = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); - - // 2. Remove import statements (MDX imports) - content = content.replace(/^import\s+.*?from\s+['"].*?['"];?\s*$/gm, ''); - - // 3. Convert HTML images to markdown - // Pattern:

...

- content = content.replace( - /

\s*\n?\s*([^\s*\n?\s*<\/p>/g, - (match, imagePath, alt) => { - // Clean the path: remove @site/static prefix - const cleanPath = imagePath.replace('@site/static/', '/'); - return `![${alt}](${cleanPath})`; - } - ); - - // 4. Convert YouTube iframes to text links - content = content.replace( - /]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]+)[^"]*"[^>]*title="([^"]*)"[^>]*>[\s\S]*?<\/iframe>/g, - 'Watch the video: [$2](https://www.youtube.com/watch?v=$1)' - ); - - // 5. Clean HTML5 video tags - keep HTML but add fallback text - content = content.replace( - /]*>\s*]*>\s*<\/video>/g, - '

Video demonstration: $1

\n' - ); - - // 6. Remove components with structured data (SEO metadata not needed in raw markdown) - content = content.replace(/[\s\S]*?<\/Head>/g, ''); - - // 7. Convert Tabs/TabItem components to readable markdown (preserve content) - content = convertTabsToMarkdown(content); - - // 8. Convert details/summary components to readable markdown (preserve content) - content = convertDetailsToMarkdown(content); - - // 9. Remove custom React/MDX components (FAQStructuredData, etc.) - // Matches both self-closing and paired tags: or ... - // This runs AFTER Tabs/details conversion to preserve their content - content = content.replace(/<[A-Z][a-zA-Z]*[\s\S]*?(?:\/>|<\/[A-Z][a-zA-Z]*>)/g, ''); - - // 10. Convert relative image paths to absolute paths using route URL directory - // Matches: ![alt](./img/file.png) or ![alt](img/file.png) - content = content.replace( - /!\[([^\]]*)\]\((\.\/)?img\/([^)]+)\)/g, - (match, alt, relPrefix, filename) => { - return `![${alt}](${routeDir}img/${filename})`; - } - ); - - // 11. Remove any leading blank lines - content = content.replace(/^\s*\n/, ''); - - return content; -} - // Normalize docsPath option to a consistent format for pathname matching // Accepts: '/docs/', '/docs', 'docs', '/' → returns '/docs/' or '/' function normalizeDocsPath(input) { diff --git a/lib/clean-markdown.js b/lib/clean-markdown.js new file mode 100644 index 0000000..d0cc8a2 --- /dev/null +++ b/lib/clean-markdown.js @@ -0,0 +1,105 @@ +const { transformOutsideCodeFences } = require('./fence-transform'); + +function convertTabsToMarkdown(content) { + const tabsPattern = /]*>([\s\S]*?)<\/Tabs>/g; + + return content.replace(tabsPattern, (fullMatch, tabsContent) => { + const tabItemPattern = /]*value="([^"]*)"[^>]*label="([^"]*)"[^>]*>([\s\S]*?)<\/TabItem>/g; + + let result = []; + let match; + + while ((match = tabItemPattern.exec(tabsContent)) !== null) { + const [, value, label, itemContent] = match; + + const cleanContent = itemContent + .split('\n') + .map(line => line.replace(/^\s{4}/, '')) + .join('\n') + .trim(); + + result.push(`**${label}:**\n\n${cleanContent}`); + } + + return result.join('\n\n---\n\n'); + }); +} + +function convertDetailsToMarkdown(content) { + const detailsPattern = /
\s*()?([^<]+)(<\/strong>)?<\/summary>([\s\S]*?)<\/details>/g; + + return content.replace(detailsPattern, (fullMatch, strongOpen, summaryText, strongClose, detailsContent) => { + const cleanContent = detailsContent + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('\n') + .trim(); + + return `### ${summaryText.trim()}\n\n${cleanContent}`; + }); +} + +function cleanMarkdownForDisplay(content, routeDir) { + // Strip YAML front matter (always at top, before any fence) + content = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); + + // Run all MDX/JSX transforms with fenced code blocks protected so docs + // demonstrating Docusaurus syntax keep their examples intact. + content = transformOutsideCodeFences(content, (segment) => { + // Remove import statements (MDX imports) + segment = segment.replace(/^import\s+.*?from\s+['"].*?['"];?\s*$/gm, ''); + + // Convert HTML images:

...

+ segment = segment.replace( + /

\s*\n?\s*([^\s*\n?\s*<\/p>/g, + (match, imagePath, alt) => { + const cleanPath = imagePath.replace('@site/static/', '/'); + return `![${alt}](${cleanPath})`; + } + ); + + // Convert YouTube iframes to text links + segment = segment.replace( + /]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]+)[^"]*"[^>]*title="([^"]*)"[^>]*>[\s\S]*?<\/iframe>/g, + 'Watch the video: [$2](https://www.youtube.com/watch?v=$1)' + ); + + // Clean HTML5 video tags - keep HTML but add fallback text + segment = segment.replace( + /]*>\s*]*>\s*<\/video>/g, + '

Video demonstration: $1

\n' + ); + + // Remove components with structured data + segment = segment.replace(/[\s\S]*?<\/Head>/g, ''); + + // Convert Tabs/TabItem to readable markdown (preserve content) + segment = convertTabsToMarkdown(segment); + + // Convert details/summary to readable markdown (preserve content) + segment = convertDetailsToMarkdown(segment); + + // Remove custom React/MDX components + segment = segment.replace(/<[A-Z][a-zA-Z]*[\s\S]*?(?:\/>|<\/[A-Z][a-zA-Z]*>)/g, ''); + + // Convert relative image paths to absolute paths using route URL directory + segment = segment.replace( + /!\[([^\]]*)\]\((\.\/)?img\/([^)]+)\)/g, + (match, alt, relPrefix, filename) => `![${alt}](${routeDir}img/${filename})` + ); + + return segment; + }); + + // Remove any leading blank lines + content = content.replace(/^\s*\n/, ''); + + return content; +} + +module.exports = { + cleanMarkdownForDisplay, + convertTabsToMarkdown, + convertDetailsToMarkdown, +}; diff --git a/lib/fence-transform.js b/lib/fence-transform.js new file mode 100644 index 0000000..4cc11ab --- /dev/null +++ b/lib/fence-transform.js @@ -0,0 +1,52 @@ +const fenceOpenerRe = /^ {0,3}(`{3,}|~{3,})/; + +function transformOutsideCodeFences(content, transform) { + const lines = content.split('\n'); + const fences = []; + let buffer = ''; + let fenceContent = ''; + let inFence = false; + let fenceMarker = ''; + let fenceLen = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const sep = i < lines.length - 1 ? '\n' : ''; + + if (!inFence) { + const m = line.match(fenceOpenerRe); + if (m) { + inFence = true; + fenceMarker = m[1][0]; + fenceLen = m[1].length; + fenceContent = line + sep; + } else { + buffer += line + sep; + } + } else { + fenceContent += line + sep; + const closerRe = new RegExp('^ {0,3}' + fenceMarker + '{' + fenceLen + ',}\\s*$'); + if (closerRe.test(line)) { + const idx = fences.length; + fences.push(fenceContent); + buffer += '\x00FENCE' + idx + '\x00'; + inFence = false; + fenceContent = ''; + } + } + } + + if (inFence) { + const idx = fences.length; + fences.push(fenceContent); + buffer += '\x00FENCE' + idx + '\x00'; + } + + let transformed = transform(buffer); + for (let i = 0; i < fences.length; i++) { + transformed = transformed.replace('\x00FENCE' + i + '\x00', () => fences[i]); + } + return transformed; +} + +module.exports = { transformOutsideCodeFences }; diff --git a/test/clean-markdown.test.js b/test/clean-markdown.test.js new file mode 100644 index 0000000..5c6768b --- /dev/null +++ b/test/clean-markdown.test.js @@ -0,0 +1,55 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { cleanMarkdownForDisplay } = require('../lib/clean-markdown'); + +test('preserves Tabs example inside a fenced code block', () => { + const input = [ + '# How to use Tabs', + '', + '```mdx', + "import Tabs from '@theme/Tabs';", + '', + '', + ' Body A', + '', + '```', + '', + "That's it.", + ].join('\n'); + + const result = cleanMarkdownForDisplay(input, '/docs/'); + + // Fence content must be intact + assert.match(result, //); + assert.match(result, /<\/Tabs>/); + assert.match(result, /Body A<\/TabItem>/); + assert.match(result, /import Tabs from '@theme\/Tabs';/); +}); + +test('still converts Tabs that appear OUTSIDE a fenced code block', () => { + const input = 'Body'; + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.ok(!result.includes(''), 'Tabs tag should be converted away'); + assert.match(result, /\*\*A:\*\*/); + assert.match(result, /Body/); +}); + +test('strips YAML front matter at top, but does not touch --- inside fences', () => { + const input = [ + '---', + 'title: Real Front Matter', + '---', + '', + '# Heading', + '', + '```yaml', + '---', + 'this: is fence content', + '---', + '```', + ].join('\n'); + + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.ok(!result.includes('Real Front Matter'), 'top front matter should be stripped'); + assert.match(result, /this: is fence content/, 'fence content with --- should be preserved'); +}); diff --git a/test/fence-transform.test.js b/test/fence-transform.test.js new file mode 100644 index 0000000..064ef50 --- /dev/null +++ b/test/fence-transform.test.js @@ -0,0 +1,65 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { transformOutsideCodeFences } = require('../lib/fence-transform'); + +const upper = (s) => s.toUpperCase(); + +test('applies transform to plain text with no fences', () => { + assert.equal(transformOutsideCodeFences('hello world', upper), 'HELLO WORLD'); +}); + +test('preserves content inside backtick fences, transforms outside', () => { + const input = 'before\n```\ninside fence\n```\nafter'; + const expected = 'BEFORE\n```\ninside fence\n```\nAFTER'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); + +test('preserves content inside tilde fences', () => { + const input = 'before\n~~~\ninside fence\n~~~\nafter'; + const expected = 'BEFORE\n~~~\ninside fence\n~~~\nAFTER'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); + +test('length-4 fence is not closed by a length-3 fence inside', () => { + const input = 'a\n````\n```\ninside\n```\n````\nb'; + const expected = 'A\n````\n```\ninside\n```\n````\nB'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); + +test('opener with up to 3 leading spaces is treated as a fence', () => { + const input = 'a\n ```\ninside\n ```\nb'; + const expected = 'A\n ```\ninside\n ```\nB'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); + +test('opener with 4+ leading spaces is NOT a fence (indented code by markdown rules, but we do not protect it here)', () => { + // 4-space indent in CommonMark is an indented code block, not a fenced one. We let + // the regexes hit it — but the marker on its own should not flip our state machine. + const input = ' ```\nstill outside\n ```\nafter'; + const result = transformOutsideCodeFences(input, upper); + assert.equal(result, ' ```\nSTILL OUTSIDE\n ```\nAFTER'); +}); + +test('preserves CRLF line endings', () => { + const input = 'a\r\n```\r\ncode\r\n```\r\nb'; + const expected = 'A\r\n```\r\ncode\r\n```\r\nB'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); + +test('multiple fences: each protects its own content', () => { + const input = 'a\n```\nx\n```\nb\n```\ny\n```\nc'; + const expected = 'A\n```\nx\n```\nB\n```\ny\n```\nC'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); + +test('unclosed fence: rest of content is treated as code', () => { + const input = 'before\n```\nnever closed\nmore code'; + const expected = 'BEFORE\n```\nnever closed\nmore code'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); + +test('mixed fence markers: backtick opener is not closed by tilde', () => { + const input = 'a\n```\n~~~\nstill code\n```\nb'; + const expected = 'A\n```\n~~~\nstill code\n```\nB'; + assert.equal(transformOutsideCodeFences(input, upper), expected); +}); From bf6fa37d2f7734cca889a7f8e206e17697d76f6e Mon Sep 17 00:00:00 2001 From: Nader Jaber Date: Sun, 3 May 2026 14:13:54 +0300 Subject: [PATCH 3/7] fix: harden cleanMarkdownForDisplay regexes against valid MDX Several transforms in cleanMarkdownForDisplay baked in single attribute order or single-line layout, silently mangling valid MDX: - TabItem regex required value-before-label, so reverse-order TabItems caused exec to return null and the entire block was replaced with empty output. Capture all attrs and parse value/label independently with quote support. On no parseable items, return the original block instead of silently emitting nothing. - YouTube iframe regex required src-before-title and only matched double quotes. Capture iframe attrs and parse src/title independently. - Import stripper used `.*?` which cannot cross newlines, so multi-line imports survived. Use `[\s\S]*?` and make the from-clause optional so side-effect imports (`import './x.css';`) are also removed. - Component scrubber's closing-tag alternation matched any uppercase tag, leaving orphan close tags from sibling components. Backreference the captured opening name. Nested same-name components are still imperfect (regex limitation) but well-formed nested-different-name cases now scrub cleanly. -
regex rejected attributes on the opening tag, mixed-content summaries, and the body cleanup destroyed code-fence indentation and blank lines. Allow attrs on
and , strip inner tags from the summary, and only trim outer blank padding from the body so 4-space indents and intentional blank lines survive. --- lib/clean-markdown.js | 61 ++++++++++-------- test/regex-hardening.test.js | 116 +++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 test/regex-hardening.test.js diff --git a/lib/clean-markdown.js b/lib/clean-markdown.js index d0cc8a2..be0a6c1 100644 --- a/lib/clean-markdown.js +++ b/lib/clean-markdown.js @@ -1,16 +1,21 @@ const { transformOutsideCodeFences } = require('./fence-transform'); +const tabItemPattern = /]*)>([\s\S]*?)<\/TabItem>/g; +const valueAttrRe = /\bvalue\s*=\s*["']([^"']*)["']/; +const labelAttrRe = /\blabel\s*=\s*["']([^"']*)["']/; + function convertTabsToMarkdown(content) { const tabsPattern = /]*>([\s\S]*?)<\/Tabs>/g; return content.replace(tabsPattern, (fullMatch, tabsContent) => { - const tabItemPattern = /]*value="([^"]*)"[^>]*label="([^"]*)"[^>]*>([\s\S]*?)<\/TabItem>/g; - - let result = []; + const result = []; + tabItemPattern.lastIndex = 0; let match; - while ((match = tabItemPattern.exec(tabsContent)) !== null) { - const [, value, label, itemContent] = match; + const [, attrs, itemContent] = match; + const labelMatch = attrs.match(labelAttrRe); + if (!labelMatch) continue; + const label = labelMatch[1]; const cleanContent = itemContent .split('\n') @@ -21,22 +26,18 @@ function convertTabsToMarkdown(content) { result.push(`**${label}:**\n\n${cleanContent}`); } + if (result.length === 0) return fullMatch; return result.join('\n\n---\n\n'); }); } function convertDetailsToMarkdown(content) { - const detailsPattern = /
\s*()?([^<]+)(<\/strong>)?<\/summary>([\s\S]*?)<\/details>/g; - - return content.replace(detailsPattern, (fullMatch, strongOpen, summaryText, strongClose, detailsContent) => { - const cleanContent = detailsContent - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .join('\n') - .trim(); + const detailsPattern = /]*)?>\s*]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/g; - return `### ${summaryText.trim()}\n\n${cleanContent}`; + return content.replace(detailsPattern, (fullMatch, summaryHtml, body) => { + const summaryText = summaryHtml.replace(/<[^>]+>/g, '').trim(); + const cleanBody = body.replace(/^\s*\n/, '').replace(/\n\s*$/, ''); + return `### ${summaryText}\n\n${cleanBody}`; }); } @@ -44,11 +45,12 @@ function cleanMarkdownForDisplay(content, routeDir) { // Strip YAML front matter (always at top, before any fence) content = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); - // Run all MDX/JSX transforms with fenced code blocks protected so docs - // demonstrating Docusaurus syntax keep their examples intact. content = transformOutsideCodeFences(content, (segment) => { - // Remove import statements (MDX imports) - segment = segment.replace(/^import\s+.*?from\s+['"].*?['"];?\s*$/gm, ''); + // Remove import statements (default, named multi-line, and side-effect) + segment = segment.replace( + /^import\s+(?:[\s\S]*?from\s+)?['"][^'"\n]*['"];?\s*$/gm, + '' + ); // Convert HTML images:

...

segment = segment.replace( @@ -59,13 +61,21 @@ function cleanMarkdownForDisplay(content, routeDir) { } ); - // Convert YouTube iframes to text links + // Convert YouTube iframes to text links (attribute order independent, single or double quotes) segment = segment.replace( - /]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]+)[^"]*"[^>]*title="([^"]*)"[^>]*>[\s\S]*?<\/iframe>/g, - 'Watch the video: [$2](https://www.youtube.com/watch?v=$1)' + /]*)>[\s\S]*?<\/iframe>/g, + (fullMatch, attrs) => { + const srcMatch = attrs.match( + /\bsrc\s*=\s*["']https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]+)[^"']*["']/ + ); + if (!srcMatch) return fullMatch; + const titleMatch = attrs.match(/\btitle\s*=\s*["']([^"']*)["']/); + const title = titleMatch ? titleMatch[1] : 'Video'; + return `Watch the video: [${title}](https://www.youtube.com/watch?v=${srcMatch[1]})`; + } ); - // Clean HTML5 video tags - keep HTML but add fallback text + // Clean HTML5 video tags segment = segment.replace( /]*>\s*]*>\s*<\/video>/g, '' @@ -80,8 +90,9 @@ function cleanMarkdownForDisplay(content, routeDir) { // Convert details/summary to readable markdown (preserve content) segment = convertDetailsToMarkdown(segment); - // Remove custom React/MDX components - segment = segment.replace(/<[A-Z][a-zA-Z]*[\s\S]*?(?:\/>|<\/[A-Z][a-zA-Z]*>)/g, ''); + // Remove custom React/MDX components (paired tags use a backreference so different + // sibling components do not produce orphan close tags) + segment = segment.replace(/<([A-Z]\w*)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1>)/g, ''); // Convert relative image paths to absolute paths using route URL directory segment = segment.replace( diff --git a/test/regex-hardening.test.js b/test/regex-hardening.test.js new file mode 100644 index 0000000..81c2086 --- /dev/null +++ b/test/regex-hardening.test.js @@ -0,0 +1,116 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { + cleanMarkdownForDisplay, + convertTabsToMarkdown, + convertDetailsToMarkdown, +} = require('../lib/clean-markdown'); + +// --- Tabs / TabItem --- + +test('Tabs: reverse-order TabItem attrs (label before value) still convert', () => { + const input = 'Body A'; + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.match(result, /\*\*Apple:\*\*/); + assert.match(result, /Body A/); +}); + +test('Tabs: mixed-order across TabItems (one label-first, one value-first) both convert', () => { + const input = + '' + + 'First' + + 'Second' + + ''; + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.match(result, /\*\*A:\*\*[\s\S]*First/); + assert.match(result, /\*\*B:\*\*[\s\S]*Second/); +}); + +test('convertTabsToMarkdown: returns original block when no TabItems parse', () => { + const input = ''; + assert.equal(convertTabsToMarkdown(input), input); +}); + +// --- Imports --- + +test('imports: multi-line import is removed', () => { + const input = "import {\n Foo,\n Bar,\n} from './x';\n\n# Heading"; + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.ok(!result.includes('Foo'), 'import body should be gone'); + assert.ok(!result.includes('import'), 'import keyword should be gone'); + assert.match(result, /^# Heading/m); +}); + +test('imports: side-effect import is removed', () => { + const input = "import './x.css';\n\n# Heading"; + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.ok(!result.includes('import')); + assert.match(result, /^# Heading/m); +}); + +// --- Component scrubber --- + +test('component scrubber: x removes both with no orphan', () => { + const input = 'before x after'; + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.ok(!result.includes(''), 'Outer closing tag should not survive'); + assert.ok(!result.includes(''), 'Inner closing tag should not survive'); + assert.ok(!result.includes(''), 'Outer opening tag should not survive'); +}); + +// --- YouTube iframe --- + +test('YouTube iframe: title-before-src order is converted to text link', () => { + const input = + ''; + const result = cleanMarkdownForDisplay(input, '/docs/'); + assert.match( + result, + /Watch the video: \[My Video\]\(https:\/\/www\.youtube\.com\/watch\?v=abc123\)/ + ); +}); + +// ---
--- + +test('details: opening tag with attribute (
) is converted', () => { + const input = '
Hello\nbody text\n
'; + const result = convertDetailsToMarkdown(input); + assert.match(result, /### Hello/); + assert.match(result, /body text/); +}); + +test('details: summary with mixed inline content extracts text', () => { + const input = + '
Title with extra\nbody\n
'; + const result = convertDetailsToMarkdown(input); + assert.match(result, /### Title with extra/); +}); + +test('details: 4-space indented code in body is preserved (no whitespace destruction)', () => { + const input = [ + '
', + 'Hello', + '', + ' indented_code()', + '', + '
', + ].join('\n'); + const result = convertDetailsToMarkdown(input); + assert.match(result, /### Hello/); + assert.match(result, / indented_code\(\)/); +}); + +test('details: blank lines inside body are preserved (no filter)', () => { + const input = [ + '
', + 'Hello', + '', + 'first paragraph', + '', + 'second paragraph', + '', + '
', + ].join('\n'); + const result = convertDetailsToMarkdown(input); + assert.match(result, /first paragraph\n\nsecond paragraph/); +}); From 6dc14005c0e12a528c4b5527bd6ba73328047425 Mon Sep 17 00:00:00 2001 From: Nader Jaber Date: Sun, 3 May 2026 14:15:01 +0300 Subject: [PATCH 4/7] fix: copy image dirs to every route destination, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imgDirsToCopy was a Map where the !has guard locked in the destination from the first route to visit a given source dir. Sibling docs in the same source dir routed to a different URL space (e.g. via front matter slug:) had their image refs rewritten to a destination that never received any files — silent broken images at runtime. Switch to Map> via a small recordImgMapping helper, then iterate dests on copy. A source dir is now copied once per distinct destination it serves. --- index.js | 26 ++++++++++++++------------ lib/img-mapping.js | 10 ++++++++++ test/img-mapping.test.js | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 lib/img-mapping.js create mode 100644 test/img-mapping.test.js diff --git a/index.js b/index.js index b892f94..4ee965f 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const fs = require('fs-extra'); const path = require('path'); const { getMarkdownUrl } = require('./lib/markdown-path'); const { cleanMarkdownForDisplay } = require('./lib/clean-markdown'); +const { recordImgMapping } = require('./lib/img-mapping'); /** * Docusaurus plugin to copy raw markdown files to build output @@ -62,7 +63,7 @@ module.exports = function markdownSourcePlugin(context, options = {}) { console.log(`[markdown-source-plugin] Found ${mdRoutes.length} markdown routes`); let copiedCount = 0; - const imgDirsToCopy = new Map(); // sourceImgDir -> destImgDir + const imgDirsToCopy = new Map(); // sourceImgDir -> Set for (const route of mdRoutes) { const sourceRelPath = route.metadata.sourceFilePath; @@ -91,29 +92,30 @@ module.exports = function markdownSourcePlugin(context, options = {}) { console.error(` ✗ Failed to process ${sourceRelPath}:`, error.message); } - // Track img directories near this source file for copying + // Track img directories near this source file for copying. A single source + // dir may need to be copied to multiple destinations when sibling docs in the + // same dir are routed to different URL spaces (e.g. via slug:). const sourceDir = path.dirname(sourcePath); const imgDir = path.join(sourceDir, 'img'); - if (!imgDirsToCopy.has(imgDir)) { - const imgOutRelDir = stripBaseUrl(routeDir, baseUrl); - imgDirsToCopy.set(imgDir, path.join(outDir, imgOutRelDir, 'img')); - } + const imgOutRelDir = stripBaseUrl(routeDir, baseUrl); + recordImgMapping(imgDirsToCopy, imgDir, path.join(outDir, imgOutRelDir, 'img')); } console.log(`[markdown-source-plugin] Successfully processed ${copiedCount} markdown files`); - // Copy image directories + // Copy image directories. Each source dir may have multiple destinations. console.log('[markdown-source-plugin] Copying image directories...'); let imgDirCount = 0; - for (const [source, dest] of imgDirsToCopy) { - if (await fs.pathExists(source)) { + for (const [source, dests] of imgDirsToCopy) { + if (!(await fs.pathExists(source))) continue; + const imageCount = fs.readdirSync(source).length; + for (const dest of dests) { try { await fs.copy(source, dest); - const imageCount = fs.readdirSync(source).length; - console.log(` ✓ Copied: ${path.relative(context.siteDir, source)} (${imageCount} files)`); + console.log(` ✓ Copied: ${path.relative(context.siteDir, source)} → ${path.relative(outDir, dest)} (${imageCount} files)`); imgDirCount++; } catch (error) { - console.error(` ✗ Failed to copy ${path.relative(context.siteDir, source)}:`, error.message); + console.error(` ✗ Failed to copy ${path.relative(context.siteDir, source)} → ${path.relative(outDir, dest)}:`, error.message); } } } diff --git a/lib/img-mapping.js b/lib/img-mapping.js new file mode 100644 index 0000000..aa3a88c --- /dev/null +++ b/lib/img-mapping.js @@ -0,0 +1,10 @@ +function recordImgMapping(map, src, dest) { + let dests = map.get(src); + if (!dests) { + dests = new Set(); + map.set(src, dests); + } + dests.add(dest); +} + +module.exports = { recordImgMapping }; diff --git a/test/img-mapping.test.js b/test/img-mapping.test.js new file mode 100644 index 0000000..2f292ff --- /dev/null +++ b/test/img-mapping.test.js @@ -0,0 +1,32 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { recordImgMapping } = require('../lib/img-mapping'); + +test('new src adds entry with a single-element Set of destinations', () => { + const m = new Map(); + recordImgMapping(m, 'src1', 'dest1'); + assert.ok(m.get('src1') instanceof Set); + assert.deepEqual([...m.get('src1')], ['dest1']); +}); + +test('same src with a different dest grows the Set (per-route destination)', () => { + const m = new Map(); + recordImgMapping(m, 'src1', 'dest1'); + recordImgMapping(m, 'src1', 'dest2'); + assert.deepEqual([...m.get('src1')].sort(), ['dest1', 'dest2']); +}); + +test('same src with the same dest does not duplicate', () => { + const m = new Map(); + recordImgMapping(m, 'src1', 'dest1'); + recordImgMapping(m, 'src1', 'dest1'); + assert.equal(m.get('src1').size, 1); +}); + +test('different src creates independent entries', () => { + const m = new Map(); + recordImgMapping(m, 'src1', 'dest1'); + recordImgMapping(m, 'src2', 'dest2'); + assert.deepEqual([...m.get('src1')], ['dest1']); + assert.deepEqual([...m.get('src2')], ['dest2']); +}); From a49dbbdbc891b0173fb402a30223e9ee30f11633 Mon Sep 17 00:00:00 2001 From: Nader Jaber Date: Sun, 3 May 2026 14:15:55 +0300 Subject: [PATCH 5/7] fix: guard decodeURIComponent and clean up scroll-to-anchor effect The scroll-to-anchor effect in theme/Root.js had two defects: 1. decodeURIComponent ran unguarded, so a URL with a malformed hash like #%foo crashed the effect synchronously with URIError. Extract a decodeHashSafely helper that catches the error and returns null, short-circuiting the scroll attempt. 2. Four setTimeouts (100/300/500/1000 ms) and a window 'load' listener were never cleaned up. Quickly clicking different hash links piled up timers; in an SPA, 'load' had already fired before hydration so the {once: true} listener never auto-removed. The result: stale timers could scroll the page out from under the user after they navigated away. Return a cleanup that clearTimeouts every scheduled timer and removes the load listener. --- lib/decode-hash.js | 12 ++++++++++ test/decode-hash.test.js | 32 ++++++++++++++++++++++++++ theme/Root.js | 49 ++++++++++++++++++++-------------------- 3 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 lib/decode-hash.js create mode 100644 test/decode-hash.test.js diff --git a/lib/decode-hash.js b/lib/decode-hash.js new file mode 100644 index 0000000..2572190 --- /dev/null +++ b/lib/decode-hash.js @@ -0,0 +1,12 @@ +function decodeHashSafely(hash) { + if (!hash) return null; + const id = hash.startsWith('#') ? hash.substring(1) : hash; + if (!id) return null; + try { + return decodeURIComponent(id); + } catch { + return null; + } +} + +module.exports = { decodeHashSafely }; diff --git a/test/decode-hash.test.js b/test/decode-hash.test.js new file mode 100644 index 0000000..941fa47 --- /dev/null +++ b/test/decode-hash.test.js @@ -0,0 +1,32 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { decodeHashSafely } = require('../lib/decode-hash'); + +test('decodes a normal hash with leading #', () => { + assert.equal(decodeHashSafely('#install'), 'install'); +}); + +test('decodes a URL-encoded hash', () => { + assert.equal(decodeHashSafely('#hello%20world'), 'hello world'); +}); + +test('decodes UTF-8 percent encoding', () => { + assert.equal(decodeHashSafely('#%E4%B8%AD'), '中'); +}); + +test('returns null on malformed percent encoding (no throw)', () => { + assert.equal(decodeHashSafely('#%foo'), null); + assert.equal(decodeHashSafely('#%'), null); + assert.equal(decodeHashSafely('#%E4'), null); +}); + +test('handles hash without leading #', () => { + assert.equal(decodeHashSafely('install'), 'install'); +}); + +test('returns null for empty / nullish input', () => { + assert.equal(decodeHashSafely(''), null); + assert.equal(decodeHashSafely('#'), null); + assert.equal(decodeHashSafely(null), null); + assert.equal(decodeHashSafely(undefined), null); +}); diff --git a/theme/Root.js b/theme/Root.js index 987c14b..0520489 100644 --- a/theme/Root.js +++ b/theme/Root.js @@ -4,6 +4,7 @@ import { useLocation } from '@docusaurus/router'; import { createRoot } from 'react-dom/client'; import { usePluginData } from '@docusaurus/useGlobalData'; import MarkdownActionsDropdown from '../components/MarkdownActionsDropdown'; +import { decodeHashSafely } from '../lib/decode-hash'; export default function Root({ children }) { const { hash, pathname } = useLocation(); @@ -12,32 +13,30 @@ export default function Root({ children }) { const containerRef = useRef(null); useEffect(() => { - if (hash) { - const scrollToElement = () => { - const id = decodeURIComponent(hash.substring(1)); - const element = document.getElementById(id); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - return true; - } - return false; - }; - - // Try immediately - if (!scrollToElement()) { - // If element not found, wait for images and content to load - const timeouts = [100, 300, 500, 1000]; - - timeouts.forEach(delay => { - setTimeout(() => { - scrollToElement(); - }, delay); - }); - - // Also wait for images to load - window.addEventListener('load', scrollToElement, { once: true }); + if (!hash) return; + + const scrollToElement = () => { + const id = decodeHashSafely(hash); + if (!id) return false; + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + return true; } - } + return false; + }; + + if (scrollToElement()) return; + + const timeoutIds = [100, 300, 500, 1000].map(delay => + setTimeout(scrollToElement, delay) + ); + window.addEventListener('load', scrollToElement, { once: true }); + + return () => { + timeoutIds.forEach(clearTimeout); + window.removeEventListener('load', scrollToElement); + }; }, [hash]); // Inject dropdown button into article header From 83c3e6bfec0ab9536706975808f725fa11bbfce2 Mon Sep 17 00:00:00 2001 From: Nader Jaber Date: Sun, 3 May 2026 14:17:00 +0300 Subject: [PATCH 6/7] fix: track copy-reset timer in a ref and stop auto-closing the dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2 s reset timer fired by handleCopyMarkdown unconditionally called setIsOpen(false), so a user who closed the dropdown and reopened it during the cooldown would see it close again with no input. The timer was also not stored anywhere, so it could not be cleared on unmount or canceled before a new copy — slow networks could let an old timer flip state mid-flight. Track the timer in a ref, clear it on unmount via a cleanup effect, clear it again at the top of handleCopyMarkdown so a rapid second click cannot leave a stale timer running, and drop the setIsOpen(false) from the timer callback (the "Copied!" label is the feedback; the user closes the dropdown when they want to). --- components/MarkdownActionsDropdown/index.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/components/MarkdownActionsDropdown/index.js b/components/MarkdownActionsDropdown/index.js index 7d3c40a..c0622ad 100644 --- a/components/MarkdownActionsDropdown/index.js +++ b/components/MarkdownActionsDropdown/index.js @@ -5,6 +5,16 @@ export default function MarkdownActionsDropdown() { const [copied, setCopied] = useState(false); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const copyResetTimerRef = useRef(null); + + // Clear any pending copy-reset timer on unmount so it cannot fire setState + // on a torn-down component or flip UI state after the user has navigated. + useEffect(() => () => { + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current); + copyResetTimerRef.current = null; + } + }, []); // Get pathname from window.location for URL construction const currentPath = typeof window !== 'undefined' ? window.location.pathname : ''; @@ -41,6 +51,13 @@ export default function MarkdownActionsDropdown() { // Handle copying markdown to clipboard const handleCopyMarkdown = async () => { + // Cancel any in-flight reset timer up front so a stale timer can't flip + // state during a slow fetch or after a rapid second click. + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current); + copyResetTimerRef.current = null; + } + try { const response = await fetch(markdownUrl); if (!response.ok) { @@ -50,9 +67,9 @@ export default function MarkdownActionsDropdown() { await navigator.clipboard.writeText(markdown); setCopied(true); - setTimeout(() => { + copyResetTimerRef.current = setTimeout(() => { setCopied(false); - setIsOpen(false); + copyResetTimerRef.current = null; }, 2000); } catch (error) { console.error('Failed to copy markdown:', error); From 0ba3687488925155c490c8b56e46b7b5652ff1ab Mon Sep 17 00:00:00 2001 From: Nader Jaber Date: Sun, 3 May 2026 14:18:29 +0300 Subject: [PATCH 7/7] chore: release 2.2.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles seven correctness fixes from the cloud review of the 2.2.4 codebase. See CHANGELOG.md for the full list. No public API changes — all callers continue to work; behavior changes are limited to outputs that were previously buggy (collision overwrites, mangled fenced code, silent broken images, dropped Tabs blocks, malformed-hash crashes, auto-closing dropdown). --- CHANGELOG.md | 19 +++++++++++++++++++ package.json | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c680cb..f76554e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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). +## [2.2.5] - 2026-05-03 + +### Fixed +- **`intro.md` collision on `slug: '/'` routes** — sites with both `docs/intro.md` and a doc routed to `/docs/` had their build output collide on `intro.md`, with the second route silently overwriting the first. Trailing-slash routes now resolve to `index.md` in both the build writer and the dropdown URL. +- **Code fences corrupted by MDX/Docusaurus transforms** — `cleanMarkdownForDisplay` ran every regex over raw content with no fence awareness, so docs demonstrating Docusaurus syntax inside fenced code blocks had imports stripped, `` blocks rewritten, and components removed from their own examples. Transforms now skip backtick and tilde fences (length ≥ 3, ≤3 leading spaces, supports CRLF and unclosed fences) via a placeholder-based protection that also handles legitimate `` blocks containing fenced code. +- **Image directories only copied to the first route's destination** — when sibling docs in the same source dir routed to different URL spaces (e.g. via `slug:`), `imgDirsToCopy` locked the destination from the first route, leaving silent broken images on other routes. Switched to `Map>` so each source dir is copied to every destination it serves. +- **`` and YouTube iframe regexes rejected valid attribute orders** — `` required `value` before `label`; reverse-order items silently emptied the entire `` block. YouTube iframe required `src` before `title`. Both now capture the attribute string and parse fields independently with single- or double-quote support. If no ``s parse, the original `` block is preserved instead of being silently deleted. +- **Multi-line and side-effect imports survived stripping** — the import stripper used `.*?` which cannot cross newlines, so `import {\n Foo\n} from './x';` survived. Side-effect imports like `import './x.css';` weren't matched. Both are now removed. +- **Component scrubber left orphan close tags** — the closing-tag alternation matched any uppercase tag, so siblings like `x` left `` orphaned. Added a backreference so paired tags must share a name. Deeply nested same-name components remain a known regex limitation. +- **`
` regex was too strict and destroyed body whitespace** — rejected attributes on the opening tag (`
`), rejected mixed-content summaries, and the body cleanup trimmed every line and dropped blanks, breaking 4-space indents in code and intentional blank-line separators. Now allows attrs on `
`/``, strips inline tags from the summary text, and only trims outer blank padding from the body. +- **`Root.js` `decodeURIComponent` crash on malformed hash** — a URL with malformed percent encoding like `#%foo` threw `URIError` synchronously and silently lost the anchor scroll. Now wrapped in a `decodeHashSafely` helper that returns null and short-circuits the scroll attempt. +- **`Root.js` scroll-to-anchor timer and listener leaks** — four `setTimeout`s and a `window 'load'` listener were never cleaned up. Quickly clicking different hash links piled up timers, and stale timers could scroll the page out from under the user after navigation. The effect now returns a cleanup that clears every scheduled timer and removes the listener. +- **Dropdown copy-reset timer auto-closed the menu** — the 2 s reset timer unconditionally called `setIsOpen(false)`, so a user who reopened the dropdown during the cooldown saw it close again with no input. The timer was also never cleared on unmount or before a new copy. Now tracked in a `useRef`, cleared on unmount and at the top of each new copy, and only resets the "Copied!" label. + +### Internal +- Added a `node --test` based test suite (zero new dependencies). Run with `npm test`. +- Extracted `cleanMarkdownForDisplay`, `getMarkdownUrl`, fence-protection, image-mapping, and hash-decoding into focused `lib/` modules for testability. + ## [2.2.4] - 2026-03-19 ### Fixed @@ -154,6 +172,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compatible with Docusaurus v3.x - Uses React 18's createRoot API for component injection +[2.2.5]: https://github.com/FlyNumber/markdown_docusaurus_plugin/releases/tag/v2.2.5 [2.2.4]: https://github.com/FlyNumber/markdown_docusaurus_plugin/releases/tag/v2.2.4 [2.2.3]: https://github.com/FlyNumber/markdown_docusaurus_plugin/releases/tag/v2.2.3 [2.2.2]: https://github.com/FlyNumber/markdown_docusaurus_plugin/releases/tag/v2.2.2 diff --git a/package.json b/package.json index b5e3b20..e3ef420 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docusaurus-markdown-source-plugin", - "version": "2.2.4", + "version": "2.2.5", "description": "A lightweight Docusaurus plugin that exposes your markdown files as raw .md URLs, perfect for LLMs and documentation tools", "main": "index.js", "scripts": {