上一版通过设置 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>
218 lines
7.6 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|