From 1c00e5b6128e2ac466d65bde1f01a98e35f82ccf Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Wed, 15 Apr 2026 16:42:44 +0800 Subject: [PATCH] fix: enhance embed token handling in paragraphs and table cells --- src/core/render/compiler/paragraph.js | 46 +++- src/core/render/compiler/tableCell.js | 13 +- src/core/render/embed.js | 152 ++++++++----- test/integration/embed.test.js | 310 ++++++++++++++++++++++++++ test/integration/example.test.js | 191 ---------------- 5 files changed, 463 insertions(+), 249 deletions(-) create mode 100644 test/integration/embed.test.js diff --git a/src/core/render/compiler/paragraph.js b/src/core/render/compiler/paragraph.js index 3df4e3165e..c81cd66ef8 100644 --- a/src/core/render/compiler/paragraph.js +++ b/src/core/render/compiler/paragraph.js @@ -1,16 +1,48 @@ import { helper as helperTpl } from '../tpl.js'; +function renderParagraphText(text) { + if (text.startsWith('!>')) { + return helperTpl('callout important', text); + } + if (text.startsWith('?>')) { + return helperTpl('callout tip', text); + } + return /* html */ `

${text}

`; +} + export const paragraphCompiler = ({ renderer }) => - (renderer.paragraph = function ({ tokens }) { - const text = this.parser.parseInline(tokens); + (renderer.paragraph = function ({ tokens, embedTokenMap }) { let result; - if (text.startsWith('!>')) { - result = helperTpl('callout important', text); - } else if (text.startsWith('?>')) { - result = helperTpl('callout tip', text); + if (embedTokenMap && tokens?.length) { + // Keep original inline order: plain text/link tokens stay inline, include links are replaced. + const parts = []; + let inlineBuffer = []; + + const flushInlineBuffer = () => { + if (!inlineBuffer.length) { + return; + } + const text = this.parser.parseInline(inlineBuffer); + parts.push(renderParagraphText(text)); + inlineBuffer = []; + }; + + tokens.forEach((inlineToken, inlineIndex) => { + const embedToken = embedTokenMap[inlineIndex]; + if (embedToken?.length) { + flushInlineBuffer(); + parts.push(this.parser.parse(embedToken)); + } else { + inlineBuffer.push(inlineToken); + } + }); + + flushInlineBuffer(); + result = parts.join(''); } else { - result = /* html */ `

${text}

`; + const text = this.parser.parseInline(tokens); + result = renderParagraphText(text); } return result; diff --git a/src/core/render/compiler/tableCell.js b/src/core/render/compiler/tableCell.js index 063ed6682a..cc8ee44890 100644 --- a/src/core/render/compiler/tableCell.js +++ b/src/core/render/compiler/tableCell.js @@ -2,7 +2,18 @@ export const tableCellCompiler = ({ renderer }) => (renderer.tablecell = function (token) { let content; - if (token.embedTokens && token.embedTokens.length > 0) { + if (token.embedTokenMap && token.tokens?.length) { + // Preserve mixed content order: render inline tokens, replacing include links by position. + content = ''; + token.tokens.forEach((inlineToken, inlineIndex) => { + const embedToken = token.embedTokenMap[inlineIndex]; + if (embedToken?.length) { + content += this.parser.parse(embedToken); + } else { + content += this.parser.parseInline([inlineToken]); + } + }); + } else if (token.embedTokens && token.embedTokens.length > 0) { content = this.parser.parse(token.embedTokens); } else { content = this.parser.parseInline(token.tokens); diff --git a/src/core/render/embed.js b/src/core/render/embed.js index 8a55005341..2730bc78c8 100644 --- a/src/core/render/embed.js +++ b/src/core/render/embed.js @@ -33,18 +33,13 @@ function extractFragmentContent(text, fragment, fullLine) { } function walkFetchEmbed({ embedTokens, compile, fetch }, cb) { - let token; - let step = 0; - let count = 0; - if (!embedTokens.length) { return cb({}); } - while ((token = embedTokens[step++])) { - const currentToken = token; + const processStep = step => { + const currentToken = embedTokens[step]; - // eslint-disable-next-line no-loop-func const next = text => { let embedToken; if (text) { @@ -119,17 +114,21 @@ function walkFetchEmbed({ embedTokens, compile, fetch }, cb) { tokenRef: currentToken.tokenRef, }); - if (++count >= embedTokens.length) { + if (step + 1 >= embedTokens.length) { cb({}); + } else { + processStep(step + 1); } }; - if (token.embed.url) { - get(token.embed.url).then(next); + if (currentToken.embed.url) { + get(currentToken.embed.url).then(next); } else { - next(token.embed.html); + next(currentToken.embed.html); } - } + }; + + processStep(0); } export function prerenderEmbed({ compiler, raw = '', fetch }, done) { @@ -143,46 +142,56 @@ export function prerenderEmbed({ compiler, raw = '', fetch }, done) { const compile = compiler._marked; let tokens = compile.lexer(raw); const embedTokens = []; - const linkRE = compile.Lexer.rules.inline.normal.link; const links = tokens.links; - const linkMatcher = new RegExp(linkRE.source, 'g'); - tokens.forEach((token, index) => { if (token.type === 'paragraph') { - token.text = token.text.replace( - linkMatcher, - (src, filename, href, title) => { - const embed = compiler.compileEmbed(href, title); + (token.tokens || []).forEach( + ( + /** @type {{ type: string; href: any; title: any; }} */ inlineToken, + inlineIndex, + ) => { + if (inlineToken.type !== 'link') { + return; + } + + const embed = compiler.compileEmbed( + inlineToken.href, + inlineToken.title, + ); if (embed) { embedTokens.push({ index, tokenRef: token, + inlineIndex, embed, }); } - return src; }, ); } else if (token.type === 'table') { token.rows.forEach((row, rowIndex) => { row.forEach((cell, cellIndex) => { - cell.text = cell.text.replace( - linkMatcher, - (src, filename, href, title) => { - const embed = compiler.compileEmbed(href, title); - if (embed) { - embedTokens.push({ - index, - tokenRef: token, - rowIndex, - cellIndex, - embed, - }); - } - return src; - }, - ); + (cell.tokens || []).forEach((inlineToken, inlineIndex) => { + if (inlineToken.type !== 'link') { + return; + } + + const embed = compiler.compileEmbed( + inlineToken.href, + inlineToken.title, + ); + if (embed) { + embedTokens.push({ + index, + tokenRef: token, + rowIndex, + cellIndex, + inlineIndex, + embed, + }); + } + }); }); }); } @@ -192,30 +201,73 @@ export function prerenderEmbed({ compiler, raw = '', fetch }, done) { // so that we know where to insert the embedded tokens as they // are returned const moves = []; + const tokenInsertState = new WeakMap(); walkFetchEmbed( { compile, embedTokens, fetch }, ({ embedToken, token, rowIndex, cellIndex, tokenRef }) => { if (token) { + Object.assign(links, embedToken.links); + if (typeof rowIndex === 'number' && typeof cellIndex === 'number') { const cell = tokenRef.rows[rowIndex][cellIndex]; + if (typeof token.inlineIndex === 'number') { + cell.embedTokenMap ||= {}; + const existing = cell.embedTokenMap[token.inlineIndex]; + cell.embedTokenMap[token.inlineIndex] = existing + ? existing.concat(embedToken) + : embedToken; + } - cell.embedTokens = embedToken; + // Keep the flattened array for backward compatibility with older render paths. + if (cell.embedTokens && cell.embedTokens.length) { + cell.embedTokens = cell.embedTokens.concat(embedToken); + } else { + cell.embedTokens = embedToken; + } + } else if (tokenRef.type === 'paragraph') { + if (typeof token.inlineIndex === 'number') { + tokenRef.embedTokenMap ||= {}; + const existing = tokenRef.embedTokenMap[token.inlineIndex]; + tokenRef.embedTokenMap[token.inlineIndex] = existing + ? existing.concat(embedToken) + : embedToken; + } + + // Keep a flattened form as a fallback for custom renderers. + if (tokenRef.embedTokens && tokenRef.embedTokens.length) { + tokenRef.embedTokens = tokenRef.embedTokens.concat(embedToken); + } else { + tokenRef.embedTokens = embedToken; + } } else { - // iterate through the array of previously inserted tokens - // to determine where the current embedded tokens should be inserted - let index = token.index; - moves.forEach(pos => { - if (index > pos.start) { - index += pos.length; - } - }); + const state = tokenInsertState.get(tokenRef); - Object.assign(links, embedToken.links); + if (state) { + const insertAt = state.nextIndex; - tokens = tokens - .slice(0, index) - .concat(embedToken, tokens.slice(index + 1)); - moves.push({ start: index, length: embedToken.length - 1 }); + tokens = tokens + .slice(0, insertAt) + .concat(embedToken, tokens.slice(insertAt)); + moves.push({ start: insertAt, delta: embedToken.length }); + state.nextIndex = insertAt + embedToken.length; + } else { + // iterate through the array of previously inserted tokens + // to determine where the current embedded tokens should be inserted + let index = token.index; + moves.forEach(pos => { + if (index > pos.start) { + index += pos.delta; + } + }); + + tokens = tokens + .slice(0, index) + .concat(embedToken, tokens.slice(index + 1)); + moves.push({ start: index, delta: embedToken.length - 1 }); + tokenInsertState.set(tokenRef, { + nextIndex: index + embedToken.length, + }); + } } } else { cached[raw] = tokens.concat(); diff --git a/test/integration/embed.test.js b/test/integration/embed.test.js new file mode 100644 index 0000000000..49207148c3 --- /dev/null +++ b/test/integration/embed.test.js @@ -0,0 +1,310 @@ +import { waitForFunction, waitForText } from '../helpers/wait-for.js'; +import docsifyInit from '../helpers/docsify-init.js'; + +describe('Embed', function () { + test('embed file code fragment renders', async () => { + await docsifyInit({ + markdown: { + homepage: ` + # Embed Test + + [filename](_media/example1.js ':include :type=code :fragment=demo') + `, + }, + routes: { + '_media/example1.js': ` + let myURL = 'https://api.example.com/data'; + /// [demo] + const result = fetch(myURL) + .then(response => { + return response.json(); + }) + .then(myJson => { + console.log(JSON.stringify(myJson)); + }); + /// [demo] + result.then(console.log).catch(console.error); + `, + }, + }); + + // Wait for the embedded fragment to be fetched and rendered into #main + expect( + await waitForText('#main', 'console.log(JSON.stringify(myJson));'), + ).toBeTruthy(); + + const mainText = document.querySelector('#main').textContent; + expect(mainText).not.toContain('https://api.example.com/data'); + expect(mainText).not.toContain( + 'result.then(console.log).catch(console.error);', + ); + }); + + test('embed file full line fragment identifier', async () => { + await docsifyInit({ + markdown: { + homepage: ` + # Embed Test + + [filename](_media/example1.html ':include :type=code :fragment=demo :omitFragmentLine') + `, + }, + routes: { + '_media/example1.html': ` + + `, + }, + }); + + // Wait for the embedded fragment to be fetched and rendered into #main + expect( + await waitForText('#main', 'console.log(JSON.stringify(myJson));'), + ).toBeTruthy(); + + const mainText = document.querySelector('#main').textContent; + expect(mainText).not.toContain('https://api.example.com/data'); + expect(mainText).not.toContain('Full line fragment identifier'); + expect(mainText).not.toContain('-->'); + expect(mainText).not.toContain( + 'result.then(console.log).catch(console.error);', + ); + }); + + test('embed multiple file code fragments', async () => { + await docsifyInit({ + markdown: { + homepage: ` + # Embed Test + + [filename](_media/example1.js ':include :type=code :fragment=demo') + + [filename](_media/example2.js ":include :type=code :fragment=something") + + # Text between + + [filename](_media/example3.js ':include :fragment=something_else_not_code') + + [filename](_media/example4.js ':include :fragment=demo') + + # Text after + `, + }, + routes: { + '_media/example1.js': ` + let example1 = 1; + /// [demo] + example1 += 10; + /// [demo] + console.log(example1);`, + '_media/example2.js': ` + let example1 = 1; + ### [something] + example2 += 10; + ### [something] + console.log(example2);`, + '_media/example3.js': ` + let example3 = 1; + ### [something_else_not_code] + example3 += 10; + /// [something_else_not_code] + console.log(example3);`, + '_media/example4.js': ` + let example4 = 1; + ### No fragment here + example4 += 10; + /// No fragment here + console.log(example4);`, + }, + }); + + expect(await waitForText('#main', 'example1 += 10;')).toBeTruthy(); + expect(await waitForText('#main', 'example2 += 10;')).toBeTruthy(); + expect(await waitForText('#main', 'example3 += 10;')).toBeTruthy(); + + const mainText = document.querySelector('#main').textContent; + expect(mainText).toContain('Text between'); + expect(mainText).toContain('Text after'); + expect(mainText).not.toContain('let example1 = 1;'); + expect(mainText).not.toContain('let example2 = 1;'); + expect(mainText).not.toContain('let example3 = 1;'); + expect(mainText).not.toContain('console.log(example1);'); + expect(mainText).not.toContain('console.log(example2);'); + expect(mainText).not.toContain('console.log(example3);'); + expect(mainText).not.toContain('console.log(example4);'); + expect(mainText).not.toContain('example4 += 10;'); + expect(mainText).not.toContain('No fragment here'); + }); + + test('embed multiple includes in same paragraph', async () => { + await docsifyInit({ + markdown: { + homepage: ` + # Embed Test + + [first](_media/first.md ':include') middle paragraph text [second](_media/second.md ':include') + `, + }, + routes: { + '_media/first.md': 'first include content', + '_media/second.md': 'second include content', + }, + }); + + expect(await waitForText('#main', 'first include content')).toBeTruthy(); + expect(await waitForText('#main', 'second include content')).toBeTruthy(); + + const mainText = document.querySelector('#main').textContent; + const firstIndex = mainText.indexOf('first include content'); + const middleIndex = mainText.indexOf('middle paragraph text'); + const secondIndex = mainText.indexOf('second include content'); + + expect(firstIndex).toBeGreaterThan(-1); + expect(middleIndex).toBeGreaterThan(-1); + expect(secondIndex).toBeGreaterThan(-1); + expect(firstIndex).toBeLessThan(middleIndex); + expect(middleIndex).toBeLessThan(secondIndex); + expect(mainText).not.toContain("_media/first.md ':include'"); + expect(mainText).not.toContain("_media/second.md ':include'"); + }); + + test('embed multiple include code fragments in same paragraph', async () => { + await docsifyInit({ + markdown: { + homepage: ` + # Embed Test + + [first](_media/first.js ':include :type=code :fragment=demo') [second](_media/second.js ':include :type=code :fragment=demo') + `, + }, + routes: { + '_media/first.js': ` + const first = 1; + /// [demo] + console.log('first demo line'); + /// [demo] + console.log('first outside'); + `, + '_media/second.js': ` + const second = 1; + /// [demo] + console.log('second demo line'); + /// [demo] + console.log('second outside'); + `, + }, + }); + + expect( + await waitForText('#main', "console.log('first demo line');"), + ).toBeTruthy(); + expect( + await waitForText('#main', "console.log('second demo line');"), + ).toBeTruthy(); + + const mainText = document.querySelector('#main').textContent; + const firstIndex = mainText.indexOf("console.log('first demo line');"); + const secondIndex = mainText.indexOf("console.log('second demo line');"); + + expect(firstIndex).toBeGreaterThan(-1); + expect(secondIndex).toBeGreaterThan(-1); + expect(firstIndex).toBeLessThan(secondIndex); + expect(mainText).not.toContain('first outside'); + expect(mainText).not.toContain('second outside'); + }); + + test('embed multiple includes in same table cell', async () => { + await docsifyInit({ + markdown: { + homepage: ` + # Embed Test + +Command | Description | Parameters +---: | --- | --- +\`do-something\` | Does something. | [first include](_media/first.md ':include') middle table text [second include](_media/second.md ':include') + `, + }, + routes: { + '_media/first.md': 'first table include content', + '_media/second.md': 'second table include content', + }, + }); + + expect( + await waitForText('#main', 'first table include content'), + ).toBeTruthy(); + expect( + await waitForText('#main', 'second table include content'), + ).toBeTruthy(); + + const mainText = document.querySelector('#main').textContent; + const firstIndex = mainText.indexOf('first table include content'); + const middleIndex = mainText.indexOf('middle table text'); + const secondIndex = mainText.indexOf('second table include content'); + + expect(firstIndex).toBeGreaterThan(-1); + expect(middleIndex).toBeGreaterThan(-1); + expect(secondIndex).toBeGreaterThan(-1); + expect(firstIndex).toBeLessThan(middleIndex); + expect(middleIndex).toBeLessThan(secondIndex); + expect(mainText).not.toContain("_media/first.md ':include'"); + expect(mainText).not.toContain("_media/second.md ':include'"); + }); + + test('embed file table cell', async () => { + await docsifyInit({ + markdown: { + homepage: ` + # Embed Test + +Command | Description | Parameters +---: | --- | --- +**Something** | | +\`do-something\` | Does something. | [include content](_media/content.md ':include') +**Something else** | | +\`etc.\` | Etc. | | + `, + }, + routes: { + '_media/content.md': `this is include content`, + }, + }); + + const mainText = document.querySelector('#main').textContent; + expect(mainText).toContain('Something'); + expect(mainText).toContain('this is include content'); + }); + + test.each([ + { type: 'iframe', selector: 'iframe' }, + { type: 'video', selector: 'video' }, + { type: 'audio', selector: 'audio' }, + ])('embed %s escapes URL for XSS safety', async ({ type, selector }) => { + const dangerousUrl = 'https://example.com/?q=">'; + + await docsifyInit({ + markdown: { + homepage: `[media](${dangerousUrl} ':include :type=${type}')`, + }, + }); + + expect( + await waitForFunction(() => !!document.querySelector(selector)), + ).toBe(true); + + const mediaElm = document.querySelector(selector); + expect(mediaElm.getAttribute('src')).toBe(dangerousUrl); + expect(mediaElm.hasAttribute('onload')).toBe(false); + }); +}); diff --git a/test/integration/example.test.js b/test/integration/example.test.js index fd01183ec6..4f6b57378e 100644 --- a/test/integration/example.test.js +++ b/test/integration/example.test.js @@ -134,195 +134,4 @@ describe('Creating a Docsify site (integration tests in Jest)', function () { ).toBeTruthy(); expect(await waitForText('#main', 'This is a custom route')).toBeTruthy(); }); - - test('embed file code fragment renders', async () => { - await docsifyInit({ - markdown: { - homepage: ` - # Embed Test - - [filename](_media/example1.js ':include :type=code :fragment=demo') - `, - }, - routes: { - '_media/example1.js': ` - let myURL = 'https://api.example.com/data'; - /// [demo] - const result = fetch(myURL) - .then(response => { - return response.json(); - }) - .then(myJson => { - console.log(JSON.stringify(myJson)); - }); - /// [demo] - result.then(console.log).catch(console.error); - `, - }, - }); - - // Wait for the embedded fragment to be fetched and rendered into #main - expect( - await waitForText('#main', 'console.log(JSON.stringify(myJson));'), - ).toBeTruthy(); - - const mainText = document.querySelector('#main').textContent; - expect(mainText).not.toContain('https://api.example.com/data'); - expect(mainText).not.toContain( - 'result.then(console.log).catch(console.error);', - ); - }); - - test('embed file full line fragment identifier', async () => { - await docsifyInit({ - markdown: { - homepage: ` - # Embed Test - - [filename](_media/example1.html ':include :type=code :fragment=demo :omitFragmentLine') - `, - }, - routes: { - '_media/example1.html': ` - - `, - }, - }); - - // Wait for the embedded fragment to be fetched and rendered into #main - expect( - await waitForText('#main', 'console.log(JSON.stringify(myJson));'), - ).toBeTruthy(); - - const mainText = document.querySelector('#main').textContent; - expect(mainText).not.toContain('https://api.example.com/data'); - expect(mainText).not.toContain('Full line fragment identifier'); - expect(mainText).not.toContain('-->'); - expect(mainText).not.toContain( - 'result.then(console.log).catch(console.error);', - ); - }); - - test('embed multiple file code fragments', async () => { - await docsifyInit({ - markdown: { - homepage: ` - # Embed Test - - [filename](_media/example1.js ':include :type=code :fragment=demo') - - [filename](_media/example2.js ":include :type=code :fragment=something") - - # Text between - - [filename](_media/example3.js ':include :fragment=something_else_not_code') - - [filename](_media/example4.js ':include :fragment=demo') - - # Text after - `, - }, - routes: { - '_media/example1.js': ` - let example1 = 1; - /// [demo] - example1 += 10; - /// [demo] - console.log(example1);`, - '_media/example2.js': ` - let example1 = 1; - ### [something] - example2 += 10; - ### [something] - console.log(example2);`, - '_media/example3.js': ` - let example3 = 1; - ### [something_else_not_code] - example3 += 10; - /// [something_else_not_code] - console.log(example3);`, - '_media/example4.js': ` - let example4 = 1; - ### No fragment here - example4 += 10; - /// No fragment here - console.log(example4);`, - }, - }); - - expect(await waitForText('#main', 'example1 += 10;')).toBeTruthy(); - expect(await waitForText('#main', 'example2 += 10;')).toBeTruthy(); - expect(await waitForText('#main', 'example3 += 10;')).toBeTruthy(); - - const mainText = document.querySelector('#main').textContent; - expect(mainText).toContain('Text between'); - expect(mainText).toContain('Text after'); - expect(mainText).not.toContain('let example1 = 1;'); - expect(mainText).not.toContain('let example2 = 1;'); - expect(mainText).not.toContain('let example3 = 1;'); - expect(mainText).not.toContain('console.log(example1);'); - expect(mainText).not.toContain('console.log(example2);'); - expect(mainText).not.toContain('console.log(example3);'); - expect(mainText).not.toContain('console.log(example4);'); - expect(mainText).not.toContain('example4 += 10;'); - expect(mainText).not.toContain('No fragment here'); - }); - - test('embed file table cell', async () => { - await docsifyInit({ - markdown: { - homepage: ` - # Embed Test - -Command | Description | Parameters ----: | --- | --- -**Something** | | -\`do-something\` | Does something. | [include content](_media/content.md ':include') -**Something else** | | -\`etc.\` | Etc. | | - `, - }, - routes: { - '_media/content.md': `this is include content`, - }, - }); - - const mainText = document.querySelector('#main').textContent; - expect(mainText).toContain('Something'); - expect(mainText).toContain('this is include content'); - }); - - test.each([ - { type: 'iframe', selector: 'iframe' }, - { type: 'video', selector: 'video' }, - { type: 'audio', selector: 'audio' }, - ])('embed %s escapes URL for XSS safety', async ({ type, selector }) => { - const dangerousUrl = 'https://example.com/?q=">'; - - await docsifyInit({ - markdown: { - homepage: `[media](${dangerousUrl} ':include :type=${type}')`, - }, - }); - - expect( - await waitForFunction(() => !!document.querySelector(selector)), - ).toBe(true); - - const mediaElm = document.querySelector(selector); - expect(mediaElm.getAttribute('src')).toBe(dangerousUrl); - expect(mediaElm.hasAttribute('onload')).toBe(false); - }); });