diff --git a/lib/extend/syntax_highlight.ts b/lib/extend/syntax_highlight.ts index d5cde7e09..f2cc97ad5 100644 --- a/lib/extend/syntax_highlight.ts +++ b/lib/extend/syntax_highlight.ts @@ -5,7 +5,7 @@ export interface HighlightOptions { caption: string | undefined, lines_length?: number | undefined, - // plugins/filter/before_post_render/backtick_code_block + // hexo/post_render_processor firstLineNumber?: string | number // plugins/tag/code.ts diff --git a/lib/hexo/post.ts b/lib/hexo/post.ts index 9418039e4..972f6c181 100644 --- a/lib/hexo/post.ts +++ b/lib/hexo/post.ts @@ -1,4 +1,3 @@ -import assert from 'assert'; import moment from 'moment'; import Promise from 'bluebird'; import { join, extname, basename } from 'path'; @@ -8,337 +7,11 @@ import { slugize, escapeRegExp, deepMerge} from 'hexo-util'; import { copyDir, exists, listDir, mkdirs, readFile, rmdir, unlink, writeFile } from 'hexo-fs'; import { parse as yfmParse, split as yfmSplit, stringify as yfmStringify } from 'hexo-front-matter'; import type Hexo from './index'; +import PostRenderProcessor from './post_render_processor'; import type { NodeJSLikeCallback, RenderData } from '../types'; const preservedKeys = ['title', 'slug', 'path', 'layout', 'date', 'content']; -const rHexoPostRenderEscape = /([\s\S]+?)<\/hexoPostRenderCodeBlock>/g; -const rSwigTag = /(\{\{.+?\}\})|(\{#.+?#\})|(\{%.+?%\})/s; - -const rSwigPlaceHolder = /(?:<|<)!--swig\uFFFC(\d+)--(?:>|>)/g; -const rCodeBlockPlaceHolder = /(?:<|<)!--code\uFFFC(\d+)--(?:>|>)/g; -const rCommentHolder = /(?:<|<)!--comment\uFFFC(\d+)--(?:>|>)/g; - -const STATE_PLAINTEXT = 0; -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 STATE_INLINE_CODE = 6; - -const isNonWhiteSpaceChar = (char: string) => char !== '\r' - && char !== '\n' - && char !== '\t' - && char !== '\f' - && char !== '\v' - && char !== ' '; - -class PostRenderEscape { - public stored: string[]; - public length: number; - - constructor() { - this.stored = []; - } - - static escapeContent(cache: string[], flag: string, str: string) { - return ``; - } - - static restoreContent(cache: string[]) { - return (_: string, index: number) => { - assert(cache[index]); - const value = cache[index]; - cache[index] = null; - return value; - }; - } - - restoreAllSwigTags(str: string) { - const restored = str.replace(rSwigPlaceHolder, PostRenderEscape.restoreContent(this.stored)); - return restored; - } - - restoreCodeBlocks(str: string) { - return str.replace(rCodeBlockPlaceHolder, PostRenderEscape.restoreContent(this.stored)); - } - - restoreComments(str: string) { - return str.replace(rCommentHolder, PostRenderEscape.restoreContent(this.stored)); - } - - escapeCodeBlocks(str: string) { - return str.replace(rHexoPostRenderEscape, (_, content) => PostRenderEscape.escapeContent(this.stored, 'code', content)); - } - - escapeAllSwigTags(str: string) { - let state = STATE_PLAINTEXT; - let buffer_start = -1; - let plaintext_comment_start = -1; - let inline_code_backtick_count = 0; - let plain_text_start = 0; - let output = ''; - - let swig_tag_name_begin = false; - let swig_tag_name_end = false; - let swig_tag_name = ''; - - let swig_full_tag_start_start = -1; - let swig_full_tag_start_end = -1; - // current we just consider one level of string quote - let swig_string_quote = ''; - - const { length } = str; - - let idx = 0; - - // for backtracking - const swig_start_idx = [0, 0, 0, 0, 0, 0, 0]; - - const flushPlainText = (end: number) => { - if (plain_text_start !== -1 && end > plain_text_start) { - output += str.slice(plain_text_start, end); - } - plain_text_start = -1; - }; - - const ensurePlainTextStart = (position: number) => { - if (plain_text_start === -1) { - plain_text_start = position; - } - }; - - const pushAndReset = (value: string) => { - output += value; - plain_text_start = -1; - }; - - while (idx < length) { - while (idx < length) { - const char = str[idx]; - const prev_char = idx > 0 ? str[idx - 1] : ''; - const next_char = str[idx + 1]; - - if (state === STATE_PLAINTEXT) { // From plain text to swig or inline code - ensurePlainTextStart(idx); - // Check for inline code block (backticks) - if (char === '`' && prev_char !== '\\') { - // Count consecutive backticks - let backtick_count = 1; - while (str[idx + backtick_count] === '`') { - backtick_count++; - } - - flushPlainText(idx); - state = STATE_INLINE_CODE; - inline_code_backtick_count = backtick_count; - swig_start_idx[state] = idx; - idx += backtick_count - 1; // Skip the counted backticks - } else if (char === '{') { - // check if it is a complete tag {{ }} - if (next_char === '{') { - flushPlainText(idx); - state = STATE_SWIG_VAR; - idx++; - buffer_start = idx + 1; - swig_start_idx[state] = idx; - } else if (next_char === '#') { - flushPlainText(idx); - state = STATE_SWIG_COMMENT; - idx++; - buffer_start = idx + 1; - swig_start_idx[state] = idx; - } else if (next_char === '%') { - flushPlainText(idx); - state = STATE_SWIG_TAG; - idx++; - buffer_start = idx + 1; - swig_full_tag_start_start = idx + 1; - swig_full_tag_start_end = idx + 1; - swig_tag_name = ''; - swig_tag_name_begin = false; // Mark if it is the first non white space char in the swig tag - swig_tag_name_end = false; - swig_start_idx[state] = idx; - } - } else 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_INLINE_CODE) { - const inline_code_start = swig_start_idx[state]; - // Check for newline - inline code cannot span multiple lines - if ((char === '\n' && next_char === '\n') - || (char === '\r' && next_char === '\n' && str[idx + 2] === '\r' && str[idx + 3] === '\n') - || (char === '\r' && next_char === '\n' && str[idx + 2] === '\n') - || (char === '\n' && next_char === '\r' && str[idx + 2] === '\n') - ) { - // Backtrack: treat the opening backticks as plain text and retry from after them - pushAndReset(str.slice(inline_code_start, inline_code_start + inline_code_backtick_count)); - // Reset idx to position right after the opening backticks - idx = inline_code_start + inline_code_backtick_count - 1; - state = STATE_PLAINTEXT; - } else if (char === '{' && next_char === '%' && str.slice(idx).match(/^\{% *raw *%\}/)) { - // we may have raw tag in inline code - const raw_tag_end_match = str.slice(idx).match(/\{% *endraw *%\}/); - if (raw_tag_end_match) { - pushAndReset(str.slice(inline_code_start, idx)); - // escape the raw tag content - pushAndReset(PostRenderEscape.escapeContent(this.stored, 'swig', str.slice(idx, idx + raw_tag_end_match.index! + raw_tag_end_match[0].length))); - idx = idx + raw_tag_end_match.index! + raw_tag_end_match[0].length - 1; - swig_start_idx[state] = idx + 1; - } - } else if (char === '`') { - // Count consecutive backticks - let backtick_count = 1; - while (str[idx + backtick_count] === '`') { - backtick_count++; - } - - // If the count matches, we found the closing backticks - if (backtick_count === inline_code_backtick_count) { - pushAndReset(str.slice(inline_code_start, idx + backtick_count)); - idx += backtick_count - 1; // Skip the counted backticks - state = STATE_PLAINTEXT; - } - } - } else if (state === STATE_SWIG_TAG) { - if (char === '"' || char === '\'') { - if (swig_string_quote === '') { - swig_string_quote = char; - } else if (swig_string_quote === char) { - swig_string_quote = ''; - } - } - if (char === '%' && next_char === '}' && swig_string_quote === '') { // From swig back to plain text - idx++; - if (swig_tag_name !== '' && str.includes(`end${swig_tag_name}`)) { - state = STATE_SWIG_FULL_TAG; - buffer_start = idx + 1; - // since we have already move idx to next char of '}', so here is idx -1 - swig_full_tag_start_end = idx - 1; - swig_start_idx[state] = idx; - } else { - swig_tag_name = ''; - state = STATE_PLAINTEXT; - // since we have already move idx to next char of '}', so here is idx -1 - pushAndReset(PostRenderEscape.escapeContent(this.stored, 'swig', `{%${str.slice(buffer_start, idx - 1)}%}`)); - } - - } else { - if (isNonWhiteSpaceChar(char)) { - if (!swig_tag_name_begin && !swig_tag_name_end) { - swig_tag_name_begin = true; - } - - if (swig_tag_name_begin) { - swig_tag_name += char; - } - } else { - if (swig_tag_name_begin === true) { - swig_tag_name_begin = false; - swig_tag_name_end = true; - } - } - } - } else if (state === STATE_SWIG_VAR) { - if (char === '"' || char === '\'') { - if (swig_string_quote === '') { - swig_string_quote = char; - } else if (swig_string_quote === char) { - swig_string_quote = ''; - } - } - // {{ } - if (char === '}' && next_char !== '}' && swig_string_quote === '') { - // From swig back to plain text - state = STATE_PLAINTEXT; - pushAndReset(`{{${str.slice(buffer_start, idx)}${char}`); - } else if (char === '}' && next_char === '}' && swig_string_quote === '') { - pushAndReset(PostRenderEscape.escapeContent(this.stored, 'swig', `{{${str.slice(buffer_start, idx)}}}`)); - idx++; - state = STATE_PLAINTEXT; - } - } else if (state === STATE_SWIG_COMMENT) { // From swig back to plain text - if (char === '#' && next_char === '}') { - idx++; - state = STATE_PLAINTEXT; - plain_text_start = -1; - } - } else if (state === STATE_SWIG_FULL_TAG) { - if (char === '{' && next_char === '%') { - let swig_full_tag_end_buffer = ''; - let swig_full_tag_found = false; - - let _idx = idx + 2; - for (; _idx < length; _idx++) { - const _char = str[_idx]; - const _next_char = str[_idx + 1]; - - if (_char === '%' && _next_char === '}') { - _idx++; - swig_full_tag_found = true; - break; - } - - swig_full_tag_end_buffer = swig_full_tag_end_buffer + _char; - } - - if (swig_full_tag_found && swig_full_tag_end_buffer.includes(`end${swig_tag_name}`)) { - state = STATE_PLAINTEXT; - pushAndReset(PostRenderEscape.escapeContent(this.stored, 'swig', `{%${str.slice(swig_full_tag_start_start, swig_full_tag_start_end)}%}${str.slice(buffer_start, idx)}{%${swig_full_tag_end_buffer}%}`)); - idx = _idx; - 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; - } - if (state === STATE_INLINE_CODE) { - const inline_code_start = swig_start_idx[state]; - pushAndReset(str.slice(inline_code_start, inline_code_start + inline_code_backtick_count)); - // Reset idx to position right after the opening backticks - idx = inline_code_start + inline_code_backtick_count; - state = STATE_PLAINTEXT; - continue; - } - // 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)}%`); - } else { - pushAndReset('{'); - } - idx = swig_start_idx[state]; - swig_string_quote = ''; - state = STATE_PLAINTEXT; - } - - if (plain_text_start !== -1 && plain_text_start < length) { - output += str.slice(plain_text_start); - } - - return output; - } -} - const prepareFrontMatter = (data: any, jsonMode: boolean): Record => { for (const [key, item] of Object.entries(data)) { if (moment.isMoment(item)) { @@ -577,23 +250,14 @@ class Post { // front-matter overrides renderer's option if (typeof data.disableNunjucks === 'boolean') disableNunjucks = data.disableNunjucks; - const cacheObj = new PostRenderEscape(); + const processor = new PostRenderProcessor(ctx); return promise.then(content => { data.content = content; // Run "before_post_render" filters return ctx.execFilter('before_post_render', data, { context: ctx }); }).then(() => { - // Escape all comments to avoid conflict with Nunjucks and code block - data.content = cacheObj.escapeCodeBlocks(data.content); - // Escape all Nunjucks/Swig tags - let hasSwigTag = true; - if (disableNunjucks === false) { - hasSwigTag = rSwigTag.test(data.content); - if (hasSwigTag) { - data.content = cacheObj.escapeAllSwigTags(data.content); - } - } + data.content = processor.prepare(data.content, !disableNunjucks); const options: { highlight?: boolean; } = data.markdown || {}; if (!config.syntax_highlighter) options.highlight = null; @@ -607,18 +271,18 @@ class Post { toString: true, onRenderEnd(content) { // Replace cache data with real contents - data.content = cacheObj.restoreAllSwigTags(content); + data.content = processor.restoreAllSwigTags(content); + data.content = processor.restoreComments(data.content); // Return content after replace the placeholders - if (disableNunjucks || !hasSwigTag) return data.content; + if (disableNunjucks || !processor.hasNunjucks) return data.content; // Render with Nunjucks if there are Swig tags return tag.render(data.content, data); } }, options); }).then(content => { - data.content = cacheObj.restoreComments(content); - data.content = cacheObj.restoreCodeBlocks(data.content); + data.content = processor.restoreCodeBlocks(content); // Run "after_post_render" filters return ctx.execFilter('after_post_render', data, { context: ctx }); diff --git a/lib/hexo/post_render_lexer.ts b/lib/hexo/post_render_lexer.ts new file mode 100644 index 000000000..c16ab9a62 --- /dev/null +++ b/lib/hexo/post_render_lexer.ts @@ -0,0 +1,369 @@ +export const BLOCK_START = '{%'; +export const VARIABLE_START = '{{'; +export const COMMENT_START = '{#'; + +const BLOCK_END = '%}'; +const VARIABLE_END = '}}'; +const COMMENT_END = '#}'; + +interface TextSegment { + end: number; + start: number; + type: 'text'; +} + +interface HtmlCommentSegment { + end: number; + start: number; + type: 'html-comment'; +} + +interface SourceRange { + end: number; + start: number; +} + +export interface InlineCodeSegment { + end: number; + nunjucks: NunjucksToken[]; + rawBlocks: SourceRange[]; + start: number; + type: 'inline-code'; +} + +export interface FencedCodeSegment { + closed: boolean; + closingEnd: number; + contentEnd: number; + contentStart: number; + end: number; + info: string; + marker: string; + prefix: string; + start: number; + type: 'fenced-code'; +} + +export type PostSegment = TextSegment | HtmlCommentSegment | InlineCodeSegment | FencedCodeSegment; +export type NunjucksTokenType = 'block' | 'variable' | 'comment'; + +export interface NunjucksToken { + end: number; + name?: string; + start: number; + type: NunjucksTokenType; +} + +export interface PostTokens { + nunjucks: NunjucksToken[]; + segments: PostSegment[]; +} + +const countBackticks = (str: string, start: number) => { + let end = start; + while (str[end] === '`') end++; + return end - start; +}; + +const isBlankLineBoundary = (str: string, index: number) => str.startsWith('\n\n', index) + || str.startsWith('\r\n\r\n', index) + || str.startsWith('\r\n\n', index) + || str.startsWith('\n\r\n', index); + +const canStartRegex = (str: string, index: number, expressionStart: number) => index === expressionStart + || /[\s()[\]{}%*+\-~/#,:|.<>=!]/.test(str[index - 1]); + +const findExpressionEnd = (str: string, start: number, delimiter: string) => { + let mode: 'code' | 'quote' | 'regex' = 'code'; + let quote = ''; + + for (let index = start; index < str.length; index++) { + const char = str[index]; + + if (mode === 'quote') { + if (char === '\\') { + index++; + } else if (char === quote) { + mode = 'code'; + } + continue; + } + + if (mode === 'regex') { + if (char === '\\') { + index++; + } else if (char === '/') { + mode = 'code'; + } + continue; + } + + if (char === '"' || char === '\'') { + mode = 'quote'; + quote = char; + continue; + } + + if (char === 'r' && str[index + 1] === '/' && canStartRegex(str, index, start)) { + mode = 'regex'; + index++; + continue; + } + + if (str.startsWith(delimiter, index)) return index + delimiter.length; + if (char === '-' && str.startsWith(delimiter, index + 1)) return index + delimiter.length + 1; + } + + return -1; +}; + +const getBlockName = (raw: string) => { + let content = raw.slice(BLOCK_START.length, -BLOCK_END.length).trim(); + if (content.startsWith('-')) content = content.slice(1).trimStart(); + if (content.endsWith('-')) content = content.slice(0, -1).trimEnd(); + return /^([^\s]+)/.exec(content)?.[1] || ''; +}; + +const readNunjucksToken = (str: string, start: number): NunjucksToken | undefined => { + if (str.startsWith(COMMENT_START, start)) { + const closing = str.indexOf(COMMENT_END, start + COMMENT_START.length); + if (closing === -1) return; + return { type: 'comment', start, end: closing + COMMENT_END.length }; + } + + let type: 'block' | 'variable', opening: string, closing: string; + if (str.startsWith(BLOCK_START, start)) { + type = 'block'; + opening = BLOCK_START; + closing = BLOCK_END; + } else if (str.startsWith(VARIABLE_START, start)) { + type = 'variable'; + opening = VARIABLE_START; + closing = VARIABLE_END; + } else { + return; + } + + const end = findExpressionEnd(str, start + opening.length, closing); + if (end === -1) return; + + const token: NunjucksToken = { type, start, end }; + if (type === 'block') token.name = getBlockName(str.slice(start, end)); + return token; +}; + +const findRawBlockEnd = (str: string, opening: NunjucksToken) => { + let index = opening.end; + while (index < str.length) { + const start = str.indexOf(BLOCK_START, index); + if (start === -1) return; + + const token = readNunjucksToken(str, start); + if (!token) { + index = start + BLOCK_START.length; + continue; + } + if (token.name === 'endraw') return token; + index = token.end; + } +}; + +const findInlineCode = (str: string, start: number, size: number): Omit | undefined => { + const nunjucks: NunjucksToken[] = []; + const rawBlocks: SourceRange[] = []; + let index = start + size; + + while (index < str.length) { + if (isBlankLineBoundary(str, index)) return; + + if (str.startsWith(BLOCK_START, index)) { + const opening = readNunjucksToken(str, index); + if (opening) { + nunjucks.push(opening); + if (opening.name === 'raw') { + const closing = findRawBlockEnd(str, opening); + if (closing) { + nunjucks.push(closing); + rawBlocks.push({ start: opening.start, end: closing.end }); + index = closing.end; + continue; + } + } + } + } else if (str.startsWith(VARIABLE_START, index) || str.startsWith(COMMENT_START, index)) { + const token = readNunjucksToken(str, index); + if (token) nunjucks.push(token); + } + + if (str[index] === '`') { + const currentSize = countBackticks(str, index); + if (currentSize === size) { + const end = index + size; + return { + end, + nunjucks: nunjucks.filter(token => token.end <= end), + rawBlocks: rawBlocks.filter(block => block.end <= end) + }; + } + index += currentSize; + } else { + index++; + } + } +}; + +interface Fence { + char: '`' | '~'; + contentStart: number; + info: string; + marker: string; + prefix: string; + size: number; +} + +interface FenceEnd { + closed: boolean; + closingEnd: number; + contentEnd: number; + end: number; +} + +const trimHorizontalWhitespace = (str: string) => str + .replace(/^[^\S\r\n]*/, '') + .replace(/[^\S\r\n]*$/, ''); + +const findFence = (str: string, start: number): Fence | undefined => { + if (start > 0 && str[start - 1] !== '\n') return; + + const lineEnd = str.indexOf('\n', start); + if (lineEnd === -1) return; + + const line = str.slice(start, lineEnd).replace(/\r$/, ''); + const match = /^((?:(?:[^\S\r\n]*>){0,3}|[-*+]|[0-9]+\.)[^\S\r\n]*)(`{3,}|~{3,})([^\r\n]*)$/.exec(line); + if (!match) return; + + const info = trimHorizontalWhitespace(match[3]); + if (info.endsWith('`')) return; + + return { + char: match[2][0] as '`' | '~', + contentStart: lineEnd + 1, + info, + marker: match[2], + prefix: match[1], + size: match[2].length + }; +}; + +const findFenceEnd = (str: string, fence: Fence): FenceEnd => { + let lineStart = fence.contentStart; + while (lineStart < str.length) { + const lineEnd = str.indexOf('\n', lineStart); + const end = lineEnd === -1 ? str.length : lineEnd; + const closingEnd = str[end - 1] === '\r' ? end - 1 : end; + const line = str.slice(lineStart, closingEnd); + const match = /^(?:(?:[^\S\r\n]*>){0,3}[^\S\r\n]*)(`+|~+)[^\S\r\n]*$/.exec(line); + if (match && match[1][0] === fence.char && match[1].length >= fence.size) { + return { + closed: true, + closingEnd, + contentEnd: lineStart, + end: lineEnd === -1 ? str.length : lineEnd + 1 + }; + } + if (lineEnd === -1) break; + lineStart = lineEnd + 1; + } + return { + closed: false, + closingEnd: str.length, + contentEnd: str.length, + end: str.length + }; +}; + +export const lexPost = (str: string, enableNunjucks = true): PostTokens => { + const segments: PostSegment[] = []; + const nunjucks: NunjucksToken[] = []; + let textStart = 0; + let index = 0; + + const pushSegment = (segment: PostSegment) => { + if (textStart < segment.start) segments.push({ type: 'text', start: textStart, end: segment.start }); + segments.push(segment); + textStart = segment.end; + index = segment.end; + }; + + while (index < str.length) { + const fence = findFence(str, index); + if (fence) { + const fenceEnd = findFenceEnd(str, fence); + pushSegment({ + type: 'fenced-code', + start: index, + prefix: fence.prefix, + marker: fence.marker, + info: fence.info, + contentStart: fence.contentStart, + ...fenceEnd + }); + continue; + } + + if (str[index] === '`' && str[index - 1] !== '\\') { + const size = countBackticks(str, index); + const inline = findInlineCode(str, index, size); + if (inline) { + pushSegment({ type: 'inline-code', start: index, ...inline }); + continue; + } + index += size; + continue; + } + + if (str.startsWith('', index + 4); + pushSegment({ type: 'html-comment', start: index, end: commentEnd === -1 ? str.length : commentEnd + 3 }); + continue; + } + + if (enableNunjucks && str[index] === '{') { + const token = readNunjucksToken(str, index); + if (token) { + nunjucks.push(token); + index = token.end; + continue; + } + } + + index++; + } + + if (textStart < str.length) segments.push({ type: 'text', start: textStart, end: str.length }); + return { segments, nunjucks }; +}; + +export const pairNunjucksBlocks = (tokens: NunjucksToken[]) => { + const endNames = new Set(tokens + .filter(token => token.type === 'block' && token.name?.startsWith('end')) + .map(token => token.name!.slice(3))); + const stack: Array<{ index: number, name: string }> = []; + const pairs = new Map(); + + tokens.forEach((token, index) => { + if (token.type !== 'block' || !token.name) return; + if (token.name.startsWith('end')) { + const name = token.name.slice(3); + const opening = stack[stack.length - 1]; + if (opening?.name === name) { + pairs.set(opening.index, index); + stack.pop(); + } + } else if (endNames.has(token.name)) { + stack.push({ index, name: token.name }); + } + }); + + return pairs; +}; diff --git a/lib/hexo/post_render_processor.ts b/lib/hexo/post_render_processor.ts new file mode 100644 index 000000000..0a03a2bfe --- /dev/null +++ b/lib/hexo/post_render_processor.ts @@ -0,0 +1,325 @@ +import assert from 'assert'; +import type { HighlightOptions } from '../extend/syntax_highlight'; +import type Hexo from './index'; +import { + BLOCK_START, + COMMENT_START, + type FencedCodeSegment, + type NunjucksToken, + VARIABLE_START, + lexPost, + pairNunjucksBlocks +} from './post_render_lexer'; + +const rSwigPlaceHolder = /(?:<|<)!--swig\uFFFC(\d+)--(?:>|>)/g; +const rCodeBlockPlaceHolder = /(?:<|<)!--code\uFFFC(\d+)--(?:>|>)/g; +const rCommentHolder = /(?:<|<)!--comment\uFFFC(\d+)--(?:>|>)/g; + +const rAllOptions = /([^\s]+)\s+(.+?)\s+(https?:\/\/\S+|\/\S+)\s*(.+)?/; +const rLangCaption = /([^\s]+)\s*(.+)?/; +const rAdditionalOptions = /\s((?:line_number|line_threshold|first_line|wrap|mark|language_attr|highlight):\S+)/g; + +const escapeCodeBraces = (str: string) => str.replace(/{/g, '{').replace(/}/g, '}'); +const hasNunjucksSyntax = (str: string) => str.includes(VARIABLE_START) + || str.includes(BLOCK_START) + || str.includes(COMMENT_START); + +interface SourceRange { + end: number; + start: number; +} + +interface Replacement extends SourceRange { + value: string; +} + +const applyReplacements = (str: string, range: SourceRange, replacements: Replacement[]) => { + let output = ''; + let cursor = range.start; + + for (const replacement of replacements) { + assert(replacement.start >= cursor && replacement.end <= range.end); + output += str.slice(cursor, replacement.start) + replacement.value; + cursor = replacement.end; + } + + return output + str.slice(cursor, range.end); +}; + +const getSwigRanges = (tokens: NunjucksToken[]) => { + const pairs = pairNunjucksBlocks(tokens); + const ranges: SourceRange[] = []; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + const closingIndex = pairs.get(index); + if (closingIndex == null) { + ranges.push({ start: token.start, end: token.end }); + continue; + } + + ranges.push({ start: token.start, end: tokens[closingIndex].end }); + index = closingIndex; + } + + return ranges; +}; + +function parseArgs(args: string) { + const matches = []; + + let match: RegExpExecArray | null, language_attr: boolean, + line_number: boolean, line_threshold: number, wrap: boolean; + let enableHighlight = true; + while ((match = rAdditionalOptions.exec(args)) !== null) { + matches.push(match[1]); + } + + const mark: number[] = []; + let firstLine = 1; + for (const option of matches) { + const [key, value] = option.split(':'); + + switch (key) { + case 'highlight': + enableHighlight = value === 'true'; + break; + case 'line_number': + line_number = value === 'true'; + break; + case 'line_threshold': + if (!isNaN(Number(value))) line_threshold = +value; + break; + case 'first_line': + if (!isNaN(Number(value))) firstLine = +value; + break; + case 'wrap': + wrap = value === 'true'; + break; + case 'mark': { + for (const cur of value.split(',')) { + const hyphen = cur.indexOf('-'); + if (hyphen !== -1) { + let a = +cur.slice(0, hyphen); + let b = +cur.slice(hyphen + 1); + if (Number.isNaN(a) || Number.isNaN(b)) continue; + if (b < a) [a, b] = [b, a]; + + for (; a <= b; a++) mark.push(a); + } + if (!isNaN(Number(cur))) mark.push(+cur); + } + break; + } + case 'language_attr': + language_attr = value === 'true'; + break; + } + } + + return { + options: { + language_attr, + firstLine, + line_number, + line_threshold, + mark, + wrap + }, + enableHighlight, + args: args.replace(rAdditionalOptions, '') + }; +} + +class PostRenderProcessor { + private readonly codeBlocks: Array = []; + private readonly comments: Array = []; + private readonly swig: Array = []; + public hasNunjucks = false; + + constructor(private readonly ctx: Hexo) {} + + private static escapeContent(cache: Array, flag: string, str: string) { + return ``; + } + + private static restoreContent(cache: Array) { + return (_: string, index: string) => { + const value = cache[Number(index)]; + assert(value != null); + cache[Number(index)] = null; + return value; + }; + } + + restoreAllSwigTags(str: string) { + return str.replace(rSwigPlaceHolder, PostRenderProcessor.restoreContent(this.swig)); + } + + restoreCodeBlocks(str: string) { + return str.replace(rCodeBlockPlaceHolder, PostRenderProcessor.restoreContent(this.codeBlocks)); + } + + restoreComments(str: string) { + return str.replace(rCommentHolder, PostRenderProcessor.restoreContent(this.comments)); + } + + private escapeCodeBlock(str: string) { + // Keep the historical HTML output while code remains hidden from Nunjucks. + return PostRenderProcessor.escapeContent(this.codeBlocks, 'code', escapeCodeBraces(str)); + } + + private protectMarkdownCode(str: string) { + if (!hasNunjucksSyntax(str)) return str; + + return str.replace(/[{}]/g, brace => PostRenderProcessor.escapeContent( + this.codeBlocks, + 'code', + brace === '{' ? '{' : '}' + )); + } + + private escapeComment(str: string) { + // Nunjucks does not reparse expression output, so this recreates the first + // brace after rendering without evaluating the original comment contents. + const protectedComment = str.replace(/\{(?=[{%#])/g, () => { + this.hasNunjucks = true; + return '{{ "{" }}'; + }); + return PostRenderProcessor.escapeContent(this.comments, 'comment', protectedComment); + } + + private escapeSwig(str: string) { + return PostRenderProcessor.escapeContent(this.swig, 'swig', str); + } + + private renderFencedCode(str: string, segment: FencedCodeSegment, canHighlight: boolean) { + if (!segment.closed || !canHighlight) return; + + const parsedArgs = parseArgs(segment.info); + if (!parsedArgs.enableHighlight) return; + + let content = str.slice(segment.contentStart, segment.contentEnd).replace(/\r?\n$/, ''); + let args = parsedArgs.args; + const langArgs = args.split('=').shift(); + let lang: string, caption: string; + + if (langArgs) { + const match = rAllOptions.exec(langArgs) || rLangCaption.exec(langArgs); + if (match) { + lang = match[1]; + if (match[2]) { + caption = `${match[2]}`; + if (match[3]) caption += `${match[4] || 'link'}`; + } + } + } + + if (segment.prefix.includes('>')) { + const depth = segment.prefix.split('>').length - 1; + const regexp = new RegExp(`^([^\\S\\r\\n]*>){0,${depth}}([^\\S\\r\\n]|$)`, 'mg'); + content = content.replace(regexp, ''); + } + + const options: HighlightOptions = { + lang, + caption, + lines_length: content.split('\n').length, + ...parsedArgs.options + }; + args = args.replace('=+', '='); + if (args.includes('=')) options.firstLineNumber = args.split('=')[1] || 1; + + content = this.ctx.extend.highlight.exec(this.ctx.config.syntax_highlighter, { + context: this.ctx, + args: [content, options] + }); + + return segment.prefix + + this.escapeCodeBlock(content) + + str.slice(segment.closingEnd, segment.end); + } + + prepare(str: string, enableNunjucks = true) { + const containsNunjucks = enableNunjucks && hasNunjucksSyntax(str); + const canHighlight = (str.includes('```') || str.includes('~~~')) + && !!this.ctx.extend.highlight.query(this.ctx.config.syntax_highlighter); + if (!containsNunjucks && !canHighlight) return str; + + const tokens = lexPost(str, enableNunjucks); + const swigRanges = getSwigRanges(tokens.nunjucks); + const contextReplacements: Replacement[] = []; + let swigRangeIndex = 0; + + if (swigRanges.length > 0) this.hasNunjucks = true; + + for (const segment of tokens.segments) { + while (swigRanges[swigRangeIndex]?.end <= segment.start) swigRangeIndex++; + const swigRange = swigRanges[swigRangeIndex]; + const insideSwig = swigRange != null + && swigRange.start <= segment.start + && segment.end <= swigRange.end; + const value = str.slice(segment.start, segment.end); + if (segment.type === 'fenced-code') { + const rendered = this.renderFencedCode(str, segment, canHighlight); + if (rendered != null) { + contextReplacements.push({ start: segment.start, end: segment.end, value: rendered }); + } else if (enableNunjucks && hasNunjucksSyntax(value)) { + contextReplacements.push({ + start: segment.start, + end: segment.end, + value: this.protectMarkdownCode(value) + }); + } + continue; + } + + if (!enableNunjucks) continue; + if (segment.type === 'html-comment' && containsNunjucks) { + contextReplacements.push({ + start: segment.start, + end: segment.end, + value: this.escapeComment(value) + }); + continue; + } + + if (segment.type === 'inline-code' && segment.nunjucks.length > 0) { + this.hasNunjucks = true; + if (!insideSwig && segment.rawBlocks.length > 0) { + const rawReplacements = segment.rawBlocks.map(block => ({ + ...block, + value: this.escapeSwig(str.slice(block.start, block.end)) + })); + contextReplacements.push({ + start: segment.start, + end: segment.end, + value: applyReplacements(str, segment, rawReplacements) + }); + } + } + } + + const replacements: Replacement[] = []; + let contextIndex = 0; + for (const swigRange of swigRanges) { + while (contextReplacements[contextIndex]?.end <= swigRange.start) { + replacements.push(contextReplacements[contextIndex++]); + } + + const inner: Replacement[] = []; + while (contextReplacements[contextIndex]?.start < swigRange.end) { + inner.push(contextReplacements[contextIndex++]); + } + replacements.push({ + ...swigRange, + value: this.escapeSwig(applyReplacements(str, swigRange, inner)) + }); + } + replacements.push(...contextReplacements.slice(contextIndex)); + + return applyReplacements(str, { start: 0, end: str.length }, replacements); + } +} + +export default PostRenderProcessor; diff --git a/lib/plugins/filter/before_post_render/backtick_code_block.ts b/lib/plugins/filter/before_post_render/backtick_code_block.ts deleted file mode 100644 index 2097b6296..000000000 --- a/lib/plugins/filter/before_post_render/backtick_code_block.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { HighlightOptions } from '../../../extend/syntax_highlight'; -import type Hexo from '../../../hexo'; -import type { RenderData } from '../../../types'; - -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 = /()/g; -const rAdditionalOptions = /\s((?:line_number|line_threshold|first_line|wrap|mark|language_attr|highlight):\S+)/g; - -const escapeSwigTag = (str: string) => str.replace(/{/g, '{').replace(/}/g, '}'); - -function parseArgs(args: string) { - const matches = []; - - let match: RegExpExecArray | null, language_attr: boolean, - line_number: boolean, line_threshold: number, wrap: boolean; - let enableHighlight = true; - while ((match = rAdditionalOptions.exec(args)) !== null) { - matches.push(match[1]); - } - - const len = matches.length; - const mark: number[] = []; - let firstLine = 1; - for (let i = 0; i < len; i++) { - const [key, value] = matches[i].split(':'); - - switch (key) { - case 'highlight': - enableHighlight = value === 'true'; - break; - case 'line_number': - line_number = value === 'true'; - break; - case 'line_threshold': - if (!isNaN(Number(value))) line_threshold = +value; - break; - case 'first_line': - if (!isNaN(Number(value))) firstLine = +value; - break; - case 'wrap': - wrap = value === 'true'; - break; - case 'mark': { - for (const cur of value.split(',')) { - const hyphen = cur.indexOf('-'); - if (hyphen !== -1) { - let a = +cur.slice(0, hyphen); - let b = +cur.slice(hyphen + 1); - if (Number.isNaN(a) || Number.isNaN(b)) continue; - if (b < a) { // switch a & b - [a, b] = [b, a]; - } - - for (; a <= b; a++) { - mark.push(a); - } - } - if (!isNaN(Number(cur))) mark.push(+cur); - } - break; - } - case 'language_attr': { - language_attr = value === 'true'; - break; - } - } - } - return { - options: { - language_attr, - firstLine, - line_number, - line_threshold, - mark, - wrap - }, - enableHighlight, - _args: args.replace(rAdditionalOptions, '') - }; -} - -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; - // 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] - 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. - if (!ctx.extend.highlight.query(ctx.config.syntax_highlighter)) return escapeSwigTag($0); - - const parsedArgs = parseArgs(_args); - if (!parsedArgs.enableHighlight) return escapeSwigTag($0); - _args = parsedArgs._args; - - // Extract language and caption of code blocks - const args = _args.split('=').shift(); - let lang: string, caption: string; - - if (args) { - const match = rAllOptions.exec(args) || rLangCaption.exec(args); - - if (match) { - lang = match[1]; - - if (match[2]) { - caption = `${match[2]}`; - - if (match[3]) { - caption += `${match[4] ? match[4] : 'link'}`; - } - } - } - } - - // PR #3765 - if (start.includes('>')) { - // heading of last line is already removed by the top RegExp "rBacktick" - const depth = start.split('>').length - 1; - const regexp = new RegExp(`^([^\\S\\r\\n]*>){0,${depth}}([^\\S\\r\\n]|$)`, 'mg'); - content = content.replace(regexp, ''); - } - - const options: HighlightOptions = { - lang, - caption, - lines_length: content.split('\n').length, - ...parsedArgs.options - }; - // setup line number by inline - _args = _args.replace('=+', '='); - - // setup firstLineNumber; - if (_args.includes('=')) { - options.firstLineNumber = _args.split('=')[1] || 1; - } - content = ctx.extend.highlight.exec(ctx.config.syntax_highlighter, { - context: ctx, - args: [content, options] - }); - - return start - + '' - + escapeSwigTag(content) - + '' - + end; - }); - }; -}; diff --git a/lib/plugins/filter/before_post_render/index.ts b/lib/plugins/filter/before_post_render/index.ts index 501b483df..dcac82f3d 100644 --- a/lib/plugins/filter/before_post_render/index.ts +++ b/lib/plugins/filter/before_post_render/index.ts @@ -3,6 +3,5 @@ import type Hexo from '../../../hexo'; export = (ctx: Hexo) => { const { filter } = ctx.extend; - filter.register('before_post_render', require('./backtick_code_block')(ctx)); filter.register('before_post_render', require('./titlecase')); }; diff --git a/test/scripts/hexo/post.ts b/test/scripts/hexo/post.ts index 3e43ad01e..8655befe7 100644 --- a/test/scripts/hexo/post.ts +++ b/test/scripts/hexo/post.ts @@ -705,6 +705,30 @@ describe('Post', () => { afterHook.calledOnce.should.be.true; }); + it('render() - before_post_render receives the original Markdown', async () => { + const source = [ + '```js', + 'const value = {{ value }};', + '```' + ].join('\n'); + const filter = spy(data => { + data.content.should.eql(source); + }); + + hexo.extend.filter.register('before_post_render', filter); + + try { + await post.render('', { + content: source, + engine: 'markdown' + }); + + filter.calledOnce.should.be.true; + } finally { + hexo.extend.filter.unregister('before_post_render', filter); + } + }); + it('render() - callback', done => { post.render('', { content, @@ -1440,6 +1464,41 @@ describe('Post', () => { data.content.should.eql('

test

\n
{% }
'); }); + it('render() - Markdown delimiters inside a Nunjucks variable', async () => { + const data = await post.render('', { + content: '{{ "`code`" }}', + engine: 'markdown' + }); + + data.content.should.eql('`code`'); + }); + + it('render() - Markdown delimiters inside an atomic Nunjucks tag', async () => { + const tagSpy = spy(args => JSON.stringify(args)); + hexo.extend.tag.register('atomicTag', tagSpy); + + try { + const data = await post.render('', { + content: '{% atomicTag "`code`" %}', + engine: 'markdown' + }); + + data.content.should.eql('["`code`"]'); + tagSpy.calledOnce.should.be.true; + } finally { + hexo.extend.tag.unregister('atomicTag'); + } + }); + + it('render() - HTML comment delimiter inside a Nunjucks string', async () => { + const data = await post.render('', { + content: '{{ "\n([\s\S]*?)/.exec(content); + if (!match) return '
'; + return `
${match[2]}
`; + }, { + ends: true + }); + + const content = [ + '{% folding outer %}', + '{% tabs install %}', + '', + '{% folding inner %}', + 'visible content', + '{% endfolding %}', + '', + '{% endtabs %}', + '{% endfolding %}' + ].join('\n'); + + try { + const data = await post.render('', { + content, + engine: 'markdown' + }); + + data.content.should.include('data-caption="`JavaScript`"'); + data.content.should.include('visible content'); + } finally { + hexo.extend.tag.unregister('tabs'); + hexo.extend.tag.unregister('folding'); + } + }); + + it('render() - nunjucks remains literal in comment protocol', async () => { + const tagSpy = spy(); + hexo.extend.tag.register('commentProtocol', (_args, content) => { + tagSpy(content); + return ''; + }, { + ends: true + }); + + const content = [ + '{% commentProtocol %}', + '', + 'content', + '', + '{% endcommentProtocol %}' + ].join('\n'); + + try { + await post.render('', { + content, + engine: 'markdown' + }); + + tagSpy.calledOnce.should.be.true; + tagSpy.firstCall.args[0].should.include(''); + } finally { + hexo.extend.tag.unregister('commentProtocol'); + } + }); + // https://github.com/hexojs/hexo/issues/5433 it('render() - code fence nesting in comments', async () => { - const code = 'alert("Hello world")'; + const code = 'const value = {{ value }};'; const content = [ 'foo', '', + '```', + '> ~~~html', + '> ', + '> ~~~', + '``', + '' + ].join('\n'); + + const comments = lexPost(content).segments + .filter(segment => segment.type === 'html-comment') + .map(({ start, end }) => content.slice(start, end)); + + comments.should.eql(['']); + }); + + it('returns fenced code metadata for the post renderer', () => { + const content = [ + '> ```js', + '> const value = 1;', + '> ````', + 'after' + ].join('\n'); + + const fence = lexPost(content).segments.find(segment => segment.type === 'fenced-code'); + + chai.expect(fence).to.exist; + if (!fence || fence.type !== 'fenced-code') return; + fence.closed.should.be.true; + fence.prefix.should.eql('> '); + fence.marker.should.eql('```'); + fence.info.should.eql('js'); + content.slice(fence.contentStart, fence.contentEnd).should.eql('> const value = 1;\n'); + content.slice(fence.closingEnd, fence.end).should.eql('\n'); + }); + + it('treats Nunjucks tokens as opaque to Markdown and HTML delimiters', () => { + const content = [ + '{{ "`code` ', + '```njk', + '{{ "'; + const processor = new PostRenderProcessor(hexo); + + const escaped = processor.prepare(content); + const restored = processor.restoreComments(processor.restoreAllSwigTags(escaped)); + + nunjucks.renderString(restored, {}).should.eql(content); + }); + + it('preserves raw blocks containing backticks in inline code', () => { + const content = '`{% raw %}test`111`{{ value }}{% endraw %}`'; + const processor = new PostRenderProcessor(hexo); + + const escaped = processor.prepare(content); + + processor.hasNunjucks.should.be.true; + processor.restoreAllSwigTags(escaped).should.eql(content); + }); + + it('does not close a Nunjucks variable on a delimiter inside a string', () => { + const content = 'before {{ "}}" }} after'; + const processor = new PostRenderProcessor(hexo); + + const escaped = processor.prepare(content); + + (escaped.match(/' + ].join('\n'); + const data = { content }; + + codeBlock(data); + + data.content.should.eql(content); + }); + + it('processes an HTML comment inside a code fence', () => { + const source = ''; + const data = { + content: [ + '```html', + source, + '```' + ].join('\n') + }; + + codeBlock(data); + + data.content.should.eql(highlight(source, { lang: 'html' })); + }); + + it('allows a closing fence longer than the opening fence', () => { + const data = { + content: [ + '```js', + code, + '````' + ].join('\n') + }; + + codeBlock(data); + + data.content.should.eql(highlight(code, { lang: 'js' })); + }); + + it('leaves an unclosed code fence unchanged', () => { + const content = [ + '```js', + code + ].join('\n'); + const data = { content }; + + codeBlock(data); + + data.content.should.eql(content); }); // test for Issue #4190 @@ -479,7 +536,7 @@ describe('Backtick code block', () => { }; codeBlock(data); - data.content.should.eql('' + highlight(code + '\nfoo```\n\nbar```\nbaz', {}) + ''); + data.content.should.eql(highlight(code + '\nfoo```\n\nbar```\nbaz', {})); }); // test for Issue #4573 @@ -519,7 +576,7 @@ describe('Backtick code block', () => { const data = { content: createCodeWithOptions('js highlight:false') }; - const expected = escapeSwigTag(data.content); + const expected = data.content; codeBlock(data); data.content.should.eql(expected); }); @@ -533,7 +590,7 @@ describe('Backtick code block', () => { gutter: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true') @@ -543,7 +600,7 @@ describe('Backtick code block', () => { gutter: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('line_threshold', () => { @@ -555,7 +612,7 @@ describe('Backtick code block', () => { gutter: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true line_threshold:1') @@ -565,7 +622,7 @@ describe('Backtick code block', () => { gutter: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true line_threshold:3') @@ -575,7 +632,7 @@ describe('Backtick code block', () => { gutter: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('first_line', () => { @@ -587,7 +644,7 @@ describe('Backtick code block', () => { firstLine: 1234 }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js') @@ -597,7 +654,7 @@ describe('Backtick code block', () => { firstLine: 1 }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('mark', () => { @@ -623,7 +680,7 @@ describe('Backtick code block', () => { mark: [1, 7, 8, 9, 11] }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js mark:11,9-7,1', source) @@ -633,7 +690,7 @@ describe('Backtick code block', () => { mark: [1, 7, 8, 9, 11] }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('wrap', () => { @@ -645,7 +702,7 @@ describe('Backtick code block', () => { wrap: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js wrap:true') @@ -655,7 +712,7 @@ describe('Backtick code block', () => { wrap: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('language_attr', () => { @@ -667,7 +724,7 @@ describe('Backtick code block', () => { languageAttr: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('hybrid', () => { @@ -680,17 +737,17 @@ describe('Backtick code block', () => { gutter: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true line_threshold:1 Hello world https://hexo.io/ Hexo') }; codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js Hello world line_number:true line_threshold:1 https://hexo.io/ Hexo') }; codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); // https://github.com/hexojs/hexo/issues/5423 @@ -708,8 +765,8 @@ describe('Backtick code block', () => { codeBlock(data); data.content.should.eql([ - '1. ' + highlight(code, { lang: 'js' }) + '', - '2. ' + highlight(code, { lang: 'js' }) + '' + '1. ' + highlight(code, { lang: 'js' }), + '2. ' + highlight(code, { lang: 'js' }) ].join('\n')); }); @@ -728,8 +785,8 @@ describe('Backtick code block', () => { codeBlock(data); data.content.should.eql([ - '- ' + highlight(code, { lang: 'js' }) + '', - '- ' + highlight(code, { lang: 'js' }) + '' + '- ' + highlight(code, { lang: 'js' }), + '- ' + highlight(code, { lang: 'js' }) ].join('\n')); data = { @@ -745,8 +802,8 @@ describe('Backtick code block', () => { codeBlock(data); data.content.should.eql([ - '* ' + highlight(code, { lang: 'js' }) + '', - '* ' + highlight(code, { lang: 'js' }) + '' + '* ' + highlight(code, { lang: 'js' }), + '* ' + highlight(code, { lang: 'js' }) ].join('\n')); data = { @@ -762,8 +819,8 @@ describe('Backtick code block', () => { codeBlock(data); data.content.should.eql([ - '+ ' + highlight(code, { lang: 'js' }) + '', - '+ ' + highlight(code, { lang: 'js' }) + '' + '+ ' + highlight(code, { lang: 'js' }), + '+ ' + highlight(code, { lang: 'js' }) ].join('\n')); }); }); @@ -784,7 +841,7 @@ describe('Backtick code block', () => { codeBlock(data); - data.content.should.eql('' + prism(code, {lang: 'js'}) + ''); + data.content.should.eql(prism(code, {lang: 'js'})); }); it('without language name', () => { @@ -799,7 +856,7 @@ describe('Backtick code block', () => { const expected = prism(code); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); @@ -815,7 +872,7 @@ describe('Backtick code block', () => { const expected = prism(code); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('indent', () => { @@ -832,7 +889,7 @@ describe('Backtick code block', () => { const expected = prism(code, { lang: 'js' }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('line number false', () => { @@ -852,7 +909,7 @@ describe('Backtick code block', () => { }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('tab replace', () => { @@ -878,7 +935,7 @@ describe('Backtick code block', () => { }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('title', () => { @@ -896,7 +953,7 @@ describe('Backtick code block', () => { }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('prism only wrap with pre and code', () => { @@ -911,7 +968,7 @@ describe('Backtick code block', () => { const escapeSwigTag = str => str.replace(/{/g, '{').replace(/}/g, '}'); const expected = `
${escapeSwigTag(escapeHTML(code))}
`; codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); hexo.config.prismjs.exclude_languages = []; }); @@ -919,7 +976,7 @@ describe('Backtick code block', () => { const data = { content: createCodeWithOptions('js highlight:false') }; - const expected = escapeSwigTag(data.content); + const expected = data.content; codeBlock(data); data.content.should.eql(expected); }); @@ -933,7 +990,7 @@ describe('Backtick code block', () => { lineNumber: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true') @@ -943,7 +1000,7 @@ describe('Backtick code block', () => { lineNumber: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('line_threshold', () => { @@ -955,7 +1012,7 @@ describe('Backtick code block', () => { lineNumber: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true line_threshold:1') @@ -965,7 +1022,7 @@ describe('Backtick code block', () => { lineNumber: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true line_threshold:3') @@ -975,7 +1032,7 @@ describe('Backtick code block', () => { lineNumber: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('first_line', () => { @@ -987,7 +1044,7 @@ describe('Backtick code block', () => { firstLine: 1234 }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js') @@ -997,7 +1054,7 @@ describe('Backtick code block', () => { firstLine: 1 }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('mark', () => { @@ -1023,7 +1080,7 @@ describe('Backtick code block', () => { mark: [1, 7, 8, 9, 11] }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js mark:11,9-7,1', source) @@ -1033,7 +1090,7 @@ describe('Backtick code block', () => { mark: [1, 7, 8, 9, 11] }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('wrap', () => { @@ -1045,7 +1102,7 @@ describe('Backtick code block', () => { wrap: false }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js wrap:true') @@ -1055,7 +1112,7 @@ describe('Backtick code block', () => { wrap: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('language_attr', () => { @@ -1067,7 +1124,7 @@ describe('Backtick code block', () => { languageAttr: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); it('hybrid', () => { @@ -1080,17 +1137,17 @@ describe('Backtick code block', () => { lineNumber: true }); codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js line_number:true line_threshold:1 Hello world https://hexo.io/ Hexo') }; codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); data = { content: createCodeWithOptions('js Hello world line_number:true line_threshold:1 https://hexo.io/ Hexo') }; codeBlock(data); - data.content.should.eql('' + expected + ''); + data.content.should.eql(expected); }); }); });