Files
deerflow-code/frontend-web/src/core/utils/markdown.ts
T
吉鑫升andClaude Fable 5 2f414c6e08 fix: HTML 预览子页面改用 JS 切换,避免沙箱 blob 导航被拦截
上一版通过设置 location.hash 触发 CSS :target 切页,但沙箱 blob
iframe 禁止导航到自身 blob URL,浏览器报 Not allowed to load local
resource,且 :target 仅在真实片段导航时更新,注定失效。改为完全
不动 hash,用 JS 模拟 html-multipage 技能的 .page/.pages 显示隐藏
(内联 display 覆盖类级 CSS),普通目录锚点仍平滑滚动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 09:19:15 +08:00

218 lines
7.6 KiB
TypeScript

export function extractTitleFromMarkdown(markdown: string) {
if (markdown.startsWith("# ")) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return undefined;
}
export type MarkdownHeadingAnchor = {
level: number;
text: string;
id: string;
};
export type MarkdownAnchorResolver = {
headings: MarkdownHeadingAnchor[];
getHeadingId: (level: number, text: string) => string | undefined;
resolveHref: (href: string | undefined, label?: string) => string | null | undefined;
};
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
const PROTOCOL_RE = /^[a-z][a-z\d+.-]*:/i;
function stripInlineMarkdown(value: string): string {
return value
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[`*_~]/g, "")
.replace(/<[^>]+>/g, "")
.trim();
}
export function markdownSlug(value: string): string {
const cleaned = stripInlineMarkdown(value)
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s._-]+/gu, "")
.trim()
.replace(/[\s._-]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "section";
}
function decodeLinkPart(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** True for hrefs that point to an anchor within the current document (`#section`). */
export function isInPageAnchor(href: string | undefined | null): href is string {
return !!href && href.startsWith("#") && !href.startsWith("#reference-");
}
function escapeAttrValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Scrolls to an in-page anchor by id without mutating `window.location.hash`.
*
* The app runs under a HashRouter, so letting an `<a href="#...">` click change
* the hash would be interpreted as a route change and 404. We resolve the target
* element ourselves and scroll to it instead. Scoped to the nearest
* `[data-markdown-anchor-root]` so headings in other messages don't collide.
*/
export function scrollToInPageAnchor(anchor: HTMLElement, href: string | undefined | null): void {
if (!isInPageAnchor(href)) return;
const id = decodeLinkPart(href.slice(1)).trim();
if (!id) return;
const scope = anchor.closest<HTMLElement>("[data-markdown-anchor-root]");
const selector = `[id="${escapeAttrValue(id)}"]`;
const target =
(scope?.querySelector<HTMLElement>(selector) ?? null) ??
document.getElementById(id) ??
document.querySelector<HTMLElement>(selector);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function normalizeLookupKey(value: string): string {
return markdownSlug(decodeLinkPart(value).replace(/^#+/, ""));
}
function isExternalOrSpecialHref(href: string): boolean {
if (href.startsWith("//")) return true;
if (PROTOCOL_RE.test(href)) return true;
return href.startsWith("/") || href.startsWith("?");
}
function candidateTextFromHref(href: string): string {
const withoutQuery = href.split(/[?#]/)[0] ?? href;
const lastSegment = withoutQuery.split("/").filter(Boolean).pop() ?? withoutQuery;
return decodeLinkPart(lastSegment).replace(/\.(md|markdown)$/i, "");
}
/**
* Injects a small runtime script into generated HTML so that clicking an
* in-page anchor (`<a href="#section">`) works inside the preview iframe.
*
* The preview loads from a sandboxed `blob:` URL (opaque origin). Such a frame
* is FORBIDDEN from navigating to its own `blob:` URL, so changing the hash
* throws "Not allowed to load local resource: blob:…#page-x". And because CSS
* `:target` only updates on a real fragment navigation, the html-multipage
* skill's `:target`-based sub-page switching can never fire here.
*
* So we never touch the hash. Instead the handler emulates the skill's
* show/hide in JS: a "page" is a `.page` inside a `.pages` container; clicking
* a link to one shows it and hides its siblings (mirroring the
* `.page:target` + `~ #page-home` rules via inline `display`, which outranks
* the skill's class-level CSS). Plain in-page anchors (TOC headings, etc.) just
* smooth-scroll as before.
*/
export function injectInPageAnchorHandler(html: string): string {
const script = `<script>(function(){
function decode(v){try{return decodeURIComponent(v);}catch(e){return v;}}
function bySelector(id){
if(!document.querySelector)return null;
var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&");
try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;}
}
function find(raw){
var id=decode(raw);
return document.getElementById(id)||document.getElementById(raw)||
document.getElementsByName(id)[0]||document.getElementsByName(raw)[0]||
bySelector(id)||bySelector(raw);
}
function switchPage(target){
var container=target.parentElement;
while(container&&!(container.classList&&container.classList.contains("pages"))){container=container.parentElement;}
var pages=(container||document).querySelectorAll(".page");
if(!pages.length)return false;
for(var i=0;i<pages.length;i++){pages[i].style.display="none";}
target.style.display="block";
if(window.scrollTo){window.scrollTo(0,0);}
return true;
}
document.addEventListener("click",function(e){
var el=e.target;
while(el&&el.tagName!=="A"){el=el.parentElement;}
if(!el)return;
var href=el.getAttribute("href");
if(!href||href.charAt(0)!=="#"||href.length<2)return;
e.preventDefault();
var target=find(href.slice(1));
if(!target)return;
if(target.classList&&target.classList.contains("page")&&switchPage(target))return;
target.scrollIntoView({behavior:"smooth",block:"start"});
},true);
})();</script>`;
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${script}</body>`);
}
return html + script;
}
export function createMarkdownAnchorResolver(markdown: string): MarkdownAnchorResolver {
const headings: MarkdownHeadingAnchor[] = [];
const idsByLookup = new Map<string, string>();
const seen = new Map<string, number>();
let inFence = false;
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trim();
if (/^(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = HEADING_RE.exec(rawLine);
if (!match) continue;
const level = match[1]!.length;
const text = stripInlineMarkdown(match[2]!);
const baseId = markdownSlug(text);
const count = seen.get(baseId) ?? 0;
seen.set(baseId, count + 1);
const id = count === 0 ? baseId : `${baseId}-${count}`;
headings.push({ level, text, id });
idsByLookup.set(normalizeLookupKey(text), id);
idsByLookup.set(normalizeLookupKey(id), id);
}
return {
headings,
getHeadingId: (level: number, text: string) => {
void level;
return idsByLookup.get(normalizeLookupKey(text));
},
resolveHref: (href: string | undefined, label?: string) => {
if (!href) return href;
const trimmed = href.trim();
if (!trimmed || isExternalOrSpecialHref(trimmed)) return href;
const hash = trimmed.includes("#") ? trimmed.slice(trimmed.indexOf("#") + 1) : "";
const candidates = [
hash,
trimmed.startsWith("#") ? trimmed.slice(1) : "",
candidateTextFromHref(trimmed),
label ?? "",
].filter(Boolean);
for (const candidate of candidates) {
const id = idsByLookup.get(normalizeLookupKey(candidate));
if (id) return `#${id}`;
}
return trimmed.startsWith("#") ? null : null;
},
};
}