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/components/MarkdownActionsDropdown/index.js b/components/MarkdownActionsDropdown/index.js index b101a18..c0622ad 100644 --- a/components/MarkdownActionsDropdown/index.js +++ b/components/MarkdownActionsDropdown/index.js @@ -1,9 +1,20 @@ import React, { useState, useRef, useEffect } from 'react'; +import { getMarkdownUrl } from '../../lib/markdown-path'; 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 : ''; @@ -30,10 +41,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 = () => { @@ -43,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) { @@ -52,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); diff --git a/index.js b/index.js index 5f226bb..4ee965f 100644 --- a/index.js +++ b/index.js @@ -1,55 +1,14 @@ 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 * 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 => [ @@ -66,67 +25,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) { @@ -165,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; @@ -177,9 +75,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); @@ -196,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/clean-markdown.js b/lib/clean-markdown.js new file mode 100644 index 0000000..be0a6c1 --- /dev/null +++ b/lib/clean-markdown.js @@ -0,0 +1,116 @@ +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 result = []; + tabItemPattern.lastIndex = 0; + let match; + while ((match = tabItemPattern.exec(tabsContent)) !== null) { + const [, attrs, itemContent] = match; + const labelMatch = attrs.match(labelAttrRe); + if (!labelMatch) continue; + const label = labelMatch[1]; + + const cleanContent = itemContent + .split('\n') + .map(line => line.replace(/^\s{4}/, '')) + .join('\n') + .trim(); + + 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*]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/g; + + 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}`; + }); +} + +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/, ''); + + content = transformOutsideCodeFences(content, (segment) => { + // 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( + /

\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 (attribute order independent, single or double quotes) + segment = segment.replace( + /]*)>[\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 + 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 (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( + /!\[([^\]]*)\]\((\.\/)?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/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/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/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/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..e3ef420 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "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": { + "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/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/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/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); +}); 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']); +}); 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'); +}); 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/); +}); 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