Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/unified-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'cherry-markdown': patch
---

fix: 统一 `<details>` 与 `+++` 的折叠解析和渲染,修复 Markdown 正文越界及长文清洗截断容器的问题,并支持混合嵌套。
17 changes: 14 additions & 3 deletions packages/cherry-markdown/src/Engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +201,16 @@ export default class Engine {
return this.$fireHookAction(md, 'sentence', 'makeHtml', this.$dealSentenceByCache.bind(this));
}

$fireHookAction(md, type, action, actionArgs) {
$fireHookAction(md, type, action, actionArgs, hookList = this.hooks?.[type]) {
let $md = md;
const before = actionArgs?.before || '';
const method = action === 'afterMakeHtml' ? 'reduceRight' : 'reduce';
if (!this.hooks || !this.hooks[type] || !this.hooks[type][method]) {
if (!hookList || !hookList[method]) {
return $md;
}
try {
let canContinue = true;
$md = this.hooks[type][method]((newMd, oneHook) => {
$md = hookList[method]((newMd, oneHook) => {
if (!canContinue) {
return newMd;
}
Expand Down Expand Up @@ -438,6 +438,17 @@ export default class Engine {
return $md;
}

/** Render a detail body without restarting document preparation or cleanup. */
makeHtmlForDetail(md, paragraphMakeFunc) {
const hooks = this.hooks.paragraph;
const start = hooks.findIndex((hook) => hook.getName() === 'detail') + 1;
const end = hooks.findIndex((hook) => hook.getName() === 'normalParagraph');
const bodyHooks = hooks.slice(start, end < 0 ? undefined : end);
const html = this.$fireHookAction(md, 'paragraph', 'makeHtml', this.$dealSentenceByCache.bind(this), bodyHooks);
// Leave child caches opaque until the document-level restoration pass.
return paragraphMakeFunc(html);
}

makeHtmlForBlockquote(md) {
let $md = md;
$md = this.$dealParagraph($md);
Expand Down
2 changes: 1 addition & 1 deletion packages/cherry-markdown/src/core/HooksConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ const hooksConfig = [
Toc,
Blockquote,
Header, // 处理标题, 传入strict属性严格要求ATX风格标题#后带空格
Detail, // Preserve heading order and line metadata, then protect folding boundaries from lists.
Hr,
List,
Detail,
Panel,
Paragraph, // 普通段落

Expand Down
161 changes: 120 additions & 41 deletions packages/cherry-markdown/src/core/hooks/Detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@
import ParagraphBase from '@/core/ParagraphBase';
import { prependLineFeedForParagraph } from '@/utils/lineFeed';
import { getDetailRule } from '@/utils/regexp';
import { parseDetails } from '@/utils/details';
import { blockNames } from '@/utils/sanitize';

/**
* @typedef {import('@/utils/details').DetailNode} DetailNode
* @typedef {import('@/utils/details').DetailSection} DetailSection
* @typedef {(markdown: string) => { html: string, sign: string }} SentenceRenderer
*/

/**
* +++(-) 点击查看详情
* body
Expand All @@ -33,49 +40,103 @@ export default class Detail extends ParagraphBase {

constructor() {
super({ needCache: true });
this.hasDetails = false;
}

beforeMakeHtml(str) {
this.hasDetails = false;
return str;
}

afterMakeHtml(str) {
if (!this.hasDetails) return super.afterMakeHtml(str);
// A detail can be hidden in a panel/quote cache, and itself contain cached
// blocks. Restore the entire cache dependency chain before document passes
// such as TOC generation run. Only folding documents need this extra pass.
let html = str;
let previous;
do {
previous = html;
for (const hook of this.$engine.hooks.paragraph) {
if (hook instanceof ParagraphBase) html = hook.restoreCache(html);
}
} while (html !== previous);
return html;
}

makeHtml(str, sentenceMakeFunc) {
return str.replace(this.RULE.reg, (match, preLines, isOpen, title, content) => {
const lineCount = this.getLineCount(match, preLines);
let cursor = 0;
let result = '';
for (const node of parseDetails(str)) {
this.hasDetails = true;
const match = str.slice(node.start, node.end);
const sign = this.$engine.hash(match);
const testHasCache = this.testHasCache(sign);
if (testHasCache !== false) {
return prependLineFeedForParagraph(match, testHasCache);
const lineCount = this.getLineCount(match, node.preLines);
// Revisit child hooks on every render: document-level heading IDs and
// other state must also account for bodies whose text has not changed.
const cached = this.pushCache(this.$renderNode(node, str, sentenceMakeFunc), sign, lineCount);
result += str.slice(cursor, node.start) + prependLineFeedForParagraph(match, cached);
cursor = node.end;
}
return result + str.slice(cursor);
}

/**
* @param {DetailNode} node
* @param {string} source
* @param {SentenceRenderer} sentenceMakeFunc
* @param {boolean} [nested]
*/
$renderNode(node, source, sentenceMakeFunc, nested = false) {
const type = node.sections.length > 1 ? 'multiple' : 'single';
// Nested single sections need only their native details/summary structure.
// Extra wrappers triple DOM depth and make browsers flatten valid HTML.
const needsWrapper = !nested || type === 'multiple';
const html = this.$renderSections(node, source, sentenceMakeFunc, needsWrapper);
if (!needsWrapper) return html;
const raw = source.slice(node.start, node.end);
const sign = this.$engine.hash(raw);
const lines = this.getLineCount(raw, node.preLines);
return `<div class="cherry-detail cherry-detail__${type}" data-sign="${sign}" data-lines="${lines}" >${html}</div>`;
}

/**
* @param {DetailNode} node
* @param {string} source
* @param {SentenceRenderer} sentenceMakeFunc
* @param {boolean} [bodyWrapper]
*/
$renderSections(node, source, sentenceMakeFunc, bodyWrapper = true) {
let html = '';
for (const section of node.sections) {
let { start } = section;
let bodyHtml = '';
for (const child of section.children) {
bodyHtml += this.$renderBody(source.slice(start, child.start), sentenceMakeFunc);
bodyHtml += this.$renderNode(child, source, sentenceMakeFunc, true);
start = child.end;
}
const { type, html } = this.$getDetailInfo(isOpen, title, content, sentenceMakeFunc);
const ret = this.pushCache(
`<div class="cherry-detail cherry-detail__${type}" data-sign="${sign}" data-lines="${lineCount}" >${html}</div>`,
sign,
lineCount,
);
return prependLineFeedForParagraph(match, ret);
});
bodyHtml += this.$renderBody(source.slice(start, section.end), sentenceMakeFunc);
html += this.$renderSection(section, bodyHtml, sentenceMakeFunc, bodyWrapper);
}
return html;
}

// Legacy helper: delegate syntax parsing to the same parser used by makeHtml.
$getDetailInfo(isOpen, title, str, sentenceMakeFunc) {
const type = /\n\s*(\+\+|\+\+-)\s*[^\n]+\n/.test(str) ? 'multiple' : 'single';
const arr = str.split(/\n\s*(\+\+[-]{0,1}\s*[^\n]+)\n/);
let defaultOpen = isOpen === '-';
let currentTitle = title;
let html = '';
if (type === 'multiple') {
arr.forEach((item) => {
if (/^\s*\+\+/.test(item)) {
defaultOpen = /^\s*\+\+-/.test(item);
currentTitle = item.replace(/\+\+[-]{0,1}\s*([^\n]+)$/, '$1');
return true;
}
html += this.$getDetailHtml(defaultOpen, currentTitle, item, sentenceMakeFunc);
});
} else {
html = this.$getDetailHtml(defaultOpen, currentTitle, str, sentenceMakeFunc);
const source = `+++${isOpen} ${title}\n${str}\n+++`;
const [node] = parseDetails(source);
if (!node) {
return { type: 'single', html: this.$getDetailHtml(isOpen === '-', title, str, sentenceMakeFunc) };
}
return { type, html };
return {
type: node.sections.length > 1 ? 'multiple' : 'single',
html: this.$renderSections(node, source, sentenceMakeFunc),
};
}

$getDetailHtml(defaultOpen, title, str, sentenceMakeFunc) {
let ret = `<details ${defaultOpen ? 'open' : ''}>`;
$renderBody(str, sentenceMakeFunc) {
if (str.trim() === '') return '';
const paragraphProcessor = (str) => {
if (str.trim() === '') {
return '';
Expand All @@ -90,16 +151,34 @@ export default class Detail extends ParagraphBase {
}
return `<${domName}>${this.$cleanParagraph(html)}</${domName}>`;
};
ret += `<summary>${sentenceMakeFunc(title).html}</summary>`;
let $body = '';
if (this.isContainsCache(str)) {
$body = this.makeExcludingCached(str, paragraphProcessor);
} else {
$body = paragraphProcessor(str);
const paragraphs = (body) =>
this.isContainsCache(body) ? this.makeExcludingCached(body, paragraphProcessor) : paragraphProcessor(body);
return this.$engine.makeHtmlForDetail(str, paragraphs);
}

// Legacy helper: adapt its arguments to the same normalized section renderer.
$getDetailHtml(defaultOpen, title, str, sentenceMakeFunc) {
const section = {
opening: `<details ${defaultOpen ? 'open' : ''}>`,
summaryOpening: '<summary>',
title,
};
return this.$renderSection(section, this.$renderBody(str, sentenceMakeFunc), sentenceMakeFunc);
}

/**
* @param {Pick<DetailSection, 'opening' | 'summaryOpening' | 'title'>} section
* @param {string} body
* @param {SentenceRenderer} sentenceMakeFunc
* @param {boolean} [bodyWrapper]
*/
$renderSection(section, body, sentenceMakeFunc, bodyWrapper = true) {
let ret = section.opening;
if (section.summaryOpening) {
ret += `${section.summaryOpening}${sentenceMakeFunc(section.title).html}</summary>`;
}
ret += `<div class="cherry-detail-body">${$body}</div>`;
ret += `</details>`;
return ret;
ret += bodyWrapper ? `<div class="cherry-detail-body">${body}</div>` : body;
return `${ret}</details>`;
}

rule() {
Expand Down
52 changes: 33 additions & 19 deletions packages/cherry-markdown/src/core/hooks/HtmlBlock.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/
import ParagraphBase from '@/core/ParagraphBase';
import { parseDocument } from 'htmlparser2';
import {
whiteList,
convertHTMLNumberToName,
Expand Down Expand Up @@ -88,6 +89,17 @@ export default class HtmlBlock extends ParagraphBase {
let $str = str;
$str = convertHTMLNumberToName($str);
$str = escapeHTMLEntitiesWithoutSemicolon($str);
// Literal tags inside comments/attribute values must not become containers.
// Preserve the existing handling of malformed tags outside these literals.
$str = $str.replace(/<!--[\s\S]*?-->|<\/?[a-z][a-z0-9-]*(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi, (tag) => {
const escapeTags = (text) =>
text.replace(/<\/?(?:details|summary)\b[^<>]*>/gi, (literal) =>
literal.replace(/</g, '&lt;').replace(/>/g, '&gt;'),
);
return tag.startsWith('<!--')
? `<!--${escapeTags(tag.slice(4, -3))}-->`
: tag.replace(/"[^"]*"|'[^']*'/g, escapeTags);
});
$str = $str.replace(/<[/]?([^<]*?)>/g, (whole, m1) => {
if (htmlBlackList && htmlBlackList.test(m1) && !this.isAutoLinkTag(whole) && !this.isHtmlComment(whole)) {
if (/\n[\t ]*$/.test(m1)) {
Expand Down Expand Up @@ -224,29 +236,31 @@ export default class HtmlBlock extends ParagraphBase {
}
config.HTML_INTEGRATION_POINTS.foreignobject = true;

const $strArr = $str.split(/(?=<p data-sign=)/);
// A batch must never end inside a folding container (including +++ output).
const hasDetails = /<details(?:\s|>)/i.test($str);
const $strArr = hasDetails
? parseDocument($str, { withStartIndices: true, withEndIndices: true }).children.map((node) =>
$str.slice(node.startIndex, node.endIndex + 1),
)
: $str.split(/(?=<p data-sign=)/);
// 如果内容很大,则分批处理,用空间换sanitizer.sanitize消耗的时间
const batch = 50;
// 最大缓存容量(冗余20%)
const maxCacheLength = Math.max(20, Math.round((1.2 * $strArr.length) / batch));
const cacheMap = {};
if ($strArr.length > batch) {
const ret = [];
for (let i = 0; i < $strArr.length; i += batch) {
const batchStr = $strArr.slice(i, i + batch).join('');
const cacheKey = this.$engine.hashHex(batchStr);
cacheMap[cacheKey] = batchStr;
ret.push(
this.cacheAndGetData(
cacheKey,
(cacheKey) => sanitizer.sanitize(cacheMap[cacheKey], config),
maxCacheLength,
-1 * Math.round(maxCacheLength / 10),
),
);
}
return ret.join('');
if ($strArr.length <= batch && !hasDetails) return sanitizer.sanitize($str, config);
const ret = [];
for (let i = 0; i < $strArr.length; i += batch) {
const batchStr = $strArr.length <= batch ? $str : $strArr.slice(i, i + batch).join('');
const cacheKey = this.$engine.hashHex(batchStr);
ret.push(
this.cacheAndGetData(
cacheKey,
() => sanitizer.sanitize(batchStr, config),
maxCacheLength,
-1 * Math.round(maxCacheLength / 10),
),
);
}
return sanitizer.sanitize($str, config);
return ret.join('');
}
}
Loading
Loading