Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions lib/hexo/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const STATE_SWIG_VAR = 1;
const STATE_SWIG_COMMENT = 2;
const STATE_SWIG_TAG = 3;
const STATE_SWIG_FULL_TAG = 4;
const STATE_PLAINTEXT_COMMENT = 5;

const isNonWhiteSpaceChar = (char: string) => char !== '\r'
&& char !== '\n'
Expand Down Expand Up @@ -82,6 +83,7 @@ class PostRenderEscape {
escapeAllSwigTags(str: string) {
let state = STATE_PLAINTEXT;
let buffer_start = -1;
let plaintext_comment_start = -1;
let plain_text_start = 0;
let output = '';

Expand Down Expand Up @@ -153,6 +155,12 @@ class PostRenderEscape {
swig_start_idx[state] = idx;
}
}
if (char === '<' && next_char === '!' && str[idx + 2] === '-' && str[idx + 3] === '-') {
flushPlainText(idx);
state = STATE_PLAINTEXT_COMMENT;
plaintext_comment_start = idx;
idx += 3;
}
} else if (state === STATE_SWIG_TAG) {
if (char === '"' || char === '\'') {
if (swig_string_quote === '') {
Expand Down Expand Up @@ -242,12 +250,25 @@ class PostRenderEscape {
swig_full_tag_end_buffer = '';
}
}
} else if (state === STATE_PLAINTEXT_COMMENT) {
if (char === '-' && next_char === '-' && str[idx + 2] === '>') {
state = STATE_PLAINTEXT;
const comment = str.slice(plaintext_comment_start, idx + 3);
pushAndReset(PostRenderEscape.escapeContent(this.stored, 'comment', comment));
idx += 2;
}
}
idx++;
}
if (state === STATE_PLAINTEXT) {
break;
}
if (state === STATE_PLAINTEXT_COMMENT) {
// Unterminated comment, just push the rest as comment
const comment = str.slice(plaintext_comment_start, length);
pushAndReset(PostRenderEscape.escapeContent(this.stored, 'comment', comment));
break;
}
Comment on lines +266 to +271
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unterminated comment handling is duplicated between the main loop (lines 254-259) and this cleanup block. Consider extracting the comment escaping logic into a helper function to reduce duplication and improve maintainability.

Copilot uses AI. Check for mistakes.
// If the swig tag is not closed, then it is a plain text, we need to backtrack
if (state === STATE_SWIG_FULL_TAG) {
pushAndReset(`{%${str.slice(swig_full_tag_start_start, swig_full_tag_start_end)}%`);
Expand Down Expand Up @@ -513,7 +534,6 @@ class Post {
return ctx.execFilter('before_post_render', data, { context: ctx });
}).then(() => {
// Escape all comments to avoid conflict with Nunjucks and code block
data.content = cacheObj.escapeComments(data.content);
data.content = cacheObj.escapeCodeBlocks(data.content);
// Escape all Nunjucks/Swig tags
let hasSwigTag = true;
Expand Down Expand Up @@ -546,8 +566,8 @@ class Post {
}
}, options);
}).then(content => {
data.content = cacheObj.restoreCodeBlocks(content);
data.content = cacheObj.restoreComments(data.content);
data.content = cacheObj.restoreComments(content);
data.content = cacheObj.restoreCodeBlocks(data.content);

// Run "after_post_render" filters
return ctx.execFilter('after_post_render', data, { context: ctx });
Expand Down
59 changes: 23 additions & 36 deletions lib/plugins/filter/before_post_render/backtick_code_block.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import type { HighlightOptions } from '../../../extend/syntax_highlight';
import type Hexo from '../../../hexo';
import type { RenderData } from '../../../types';
import assert from 'assert';

const rBacktick = /^((?:(?:[^\S\r\n]*>){0,3}|[-*+]|[0-9]+\.)[^\S\r\n]*)(`{3,}|~{3,})[^\S\r\n]*((?:.*?[^`\s])?)[^\S\r\n]*\n((?:[\s\S]*?\n)?)(?:(?:[^\S\r\n]*>){0,3}[^\S\r\n]*)\2[^\S\r\n]?(\n+|$)/gm;
const rAllOptions = /([^\s]+)\s+(.+?)\s+(https?:\/\/\S+|\/\S+)\s*(.+)?/;
const rLangCaption = /([^\s]+)\s*(.+)?/;
const rCommentEscape = /(<!--[\s\S]*?-->)/g;
const rCommentHolder = /(?:<|&lt;)!--comment\uFFFC(\d+)--(?:>|&gt;)/g;
const rAdditionalOptions = /\s((?:line_number|line_threshold|first_line|wrap|mark|language_attr|highlight):\S+)/g;

const escapeSwigTag = (str: string) => str.replace(/{/g, '&#123;').replace(/}/g, '&#125;');
Expand Down Expand Up @@ -83,44 +81,34 @@ function parseArgs(args: string) {
};
}

class CodeBlockEscape {
public stored: string[];

constructor() {
this.stored = [];
}

static escapeContent(cache: string[], flag: string, str: string) {
return `<!--${flag}\uFFFC${cache.push(str) - 1}-->`;
}

static restoreContent(cache: string[]) {
return (_: string, index: number) => {
assert(cache[index]);
const value = cache[index];
cache[index] = null;
return value;
};
}

restoreComments(str: string) {
return str.replace(rCommentHolder, CodeBlockEscape.restoreContent(this.stored));
}

escapeComments(str: string) {
return str.replace(rCommentEscape, (_, content) => CodeBlockEscape.escapeContent(this.stored, 'comment', content));
}

}

export = (ctx: Hexo): (data: RenderData) => void => {
return function backtickCodeBlock(data: RenderData): void {
const dataContent = data.content;

if ((!dataContent.includes('```') && !dataContent.includes('~~~')) || !ctx.extend.highlight.query(ctx.config.syntax_highlighter)) return;
const cacheObj = new CodeBlockEscape();
data.content = cacheObj.escapeComments(data.content);
data.content = data.content.replace(rBacktick, ($0, start, $2, _args, _content, end) => {
// get all comment starts and ends
const commentStarts = [];
const commentEnds = [];
let match: RegExpExecArray | null;
rCommentEscape.lastIndex = 0;
while ((match = rCommentEscape.exec(dataContent)) !== null) {
commentStarts.push(match.index);
commentEnds.push(match.index + match[0].length);
}
// notice that commentStarts and commentEnds are sorted, and commentStarts[i] < commentEnds[i], commentEnds[i] <= commentStarts[i+1]
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The comment has a spacing inconsistency in the array notation. Use consistent spacing: commentStarts[i + 1] instead of commentStarts[i+1] to match code style conventions.

Suggested change
// notice that commentStarts and commentEnds are sorted, and commentStarts[i] < commentEnds[i], commentEnds[i] <= commentStarts[i+1]
// notice that commentStarts and commentEnds are sorted, and commentStarts[i] < commentEnds[i], commentEnds[i] <= commentStarts[i + 1]

Copilot uses AI. Check for mistakes.
let commentIndex = 0;
data.content = data.content.replace(rBacktick, ($0, start, $2, _args, _content, end, matchIndex) => {
// get the start and end of the code block
const codeBlockStart = matchIndex;
const codeBlockEnd = matchIndex + $0.length;
// check if the code block is nested in a comment
while (commentIndex < commentStarts.length && commentEnds[commentIndex] <= codeBlockStart) {
commentIndex++;
}
if (commentIndex < commentStarts.length && commentStarts[commentIndex] < codeBlockStart && commentEnds[commentIndex] > codeBlockEnd) {
// the code block is nested in a comment, return escaped content directly
return escapeSwigTag($0);
}
let content = _content.replace(/\n$/, '');

// neither highlight or prismjs is enabled, return escaped content directly.
Expand Down Expand Up @@ -182,6 +170,5 @@ export = (ctx: Hexo): (data: RenderData) => void => {
+ '</hexoPostRenderCodeBlock>'
+ end;
});
data.content = cacheObj.restoreComments(data.content);
};
};
109 changes: 109 additions & 0 deletions test/scripts/hexo/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,89 @@ describe('Post', () => {
].join('\n'));
});

it('render() - incomplete comments', async () => {
const content = [
'foo',
'<!--',
'test',
'{% raw %}',
'bar',
'{% endraw %}'
].join('\n');

const data = await post.render('', {
content,
engine: 'markdown'
});

data.content.should.eql([
'<p>foo</p>',
'<!--',
'test',
'{% raw %}',
'bar',
'{% endraw %}'
].join('\n'));
});

// https://github.com/hexojs/hexo/issues/5716
it('render() - comments nesting in nunjucks', async () => {
const tagSpy = spy();
hexo.extend.tag.register('testTag', (args, content) => {
tagSpy(args, content);
return '';
}, {
ends: true
});
let content = [
'{% testTag %}',
'foo',
'<!--',
'test',
'-->',
'bar',
'{% endtestTag %}'
].join('\n');

let data = await post.render('', {
content,
engine: 'markdown'
});

data.content.should.eql('');
tagSpy.calledOnce.should.be.true;
tagSpy.firstCall.args[1].should.eql([
'foo',
'<!--',
'test',
'-->',
'bar'
].join('\n'));

content = [
'{% testTag %}',
'foo',
'<!-- test -->',
'bar',
'{% endtestTag %}'
].join('\n');

data = await post.render('', {
content,
engine: 'markdown'
});

data.content.should.eql('');
tagSpy.calledTwice.should.be.true;
tagSpy.secondCall.args[1].should.eql([
'foo',
'<!-- test -->',
'bar'
].join('\n'));

hexo.extend.tag.unregister('testTag');
});

// https://github.com/hexojs/hexo/issues/5433
it('render() - code fence nesting in comments', async () => {
const code = 'alert("Hello world")';
Expand Down Expand Up @@ -1660,4 +1743,30 @@ describe('Post', () => {
''
].join('\n'));
});

// https://github.com/hexojs/hexo/issues/5715
it('render() - comment nesting in code fence', async () => {
const code = 'alert("Hello world")';
const content = [
'foo',
'```',
'<!--',
code,
'-->',
'```',
'bar'
].join('\n');

const data = await post.render('', {
content,
engine: 'markdown'
});

data.content.should.eql([
'<p>foo</p>',
'<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">&lt;!--</span><br><span class="line">alert(&quot;Hello world&quot;)</span><br><span class="line">--&gt;</span><br></pre></td></tr></table></figure>',
'<p>bar</p>',
''
].join('\n'));
});
});
Loading