mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
fix(dashboard): restore plugin README anchor navigation (#10341)
* fix(dashboard): restore plugin README anchor navigation The plugin detail page renders README via v-html but never generated heading ids and never intercepted in-page anchors. Since the router uses hash mode, clicking a table-of-contents link such as [section](#section) replaced the route hash and skipped scrolling. Generate heading ids while rendering Markdown and add a delegated click handler that scrolls to the target element without touching the router hash. Apply the same handler to the README and changelog sections, and add scroll-margin-top so the sticky header does not cover the target heading. * fix(dashboard): harden plugin README anchor handling Address review feedback on the anchor fix: - Assign heading ids against a set of all used ids and increment the suffix until the candidate is free, so a heading named like an existing duplicate suffix (A, A-1, A) no longer collides. - Always preventDefault on a[href^="#"] before resolving the target, so empty (href="#"), malformed, or missing-fragment links cannot reach the hash-mode router. - Guard decodeURIComponent with try/catch so malformed percent-encoded fragments no longer abort the handler.
This commit is contained in:
@@ -577,6 +577,36 @@ const renderMarkdown = (source) => {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = cleanHtml;
|
||||
|
||||
// Generate heading ids so README table-of-contents anchors can resolve.
|
||||
const usedIds = new Set();
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
|
||||
if (heading.id) {
|
||||
usedIds.add(heading.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const base = (heading.textContent || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
if (!base) return;
|
||||
|
||||
// Increment the suffix until the candidate no longer collides with any
|
||||
// previously assigned id (including ids coming from raw HTML headings).
|
||||
let slug = base;
|
||||
let suffix = 1;
|
||||
while (usedIds.has(slug)) {
|
||||
slug = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedIds.add(slug);
|
||||
heading.id = slug;
|
||||
});
|
||||
|
||||
container.querySelectorAll("a").forEach((link) => {
|
||||
const href = link.getAttribute("href") || "";
|
||||
if (href.startsWith("http") || href.startsWith("//")) {
|
||||
@@ -588,6 +618,33 @@ const renderMarkdown = (source) => {
|
||||
return container.innerHTML;
|
||||
};
|
||||
|
||||
const handleDocsClick = (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const anchor = target?.closest('a[href^="#"]');
|
||||
if (!anchor) return;
|
||||
|
||||
// Never let a local hash link reach the hash-mode router, even when the
|
||||
// fragment is empty, malformed, or has no matching heading.
|
||||
event.preventDefault();
|
||||
|
||||
const rawHref = anchor.getAttribute("href") || "";
|
||||
let targetId = "";
|
||||
try {
|
||||
targetId = decodeURIComponent(rawHref.slice(1));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!targetId) return;
|
||||
|
||||
// Scope lookup to the rendered container so the hash router is never touched.
|
||||
const scrollTarget = event.currentTarget.querySelector(
|
||||
`#${CSS.escape(targetId)}`,
|
||||
);
|
||||
if (!scrollTarget) return;
|
||||
|
||||
scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const updateHeaderStuckState = () => {
|
||||
const scrollTop =
|
||||
document.scrollingElement?.scrollTop ||
|
||||
@@ -1040,7 +1097,12 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="readmeEmpty" class="text-medium-emphasis">
|
||||
{{ tm("detail.docsEmpty") }}
|
||||
</div>
|
||||
<div v-else class="docs-markdown" v-html="renderedReadme"></div>
|
||||
<div
|
||||
v-else
|
||||
class="docs-markdown"
|
||||
v-html="renderedReadme"
|
||||
@click="handleDocsClick"
|
||||
></div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</section>
|
||||
@@ -1060,7 +1122,12 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="changelogEmpty" class="text-medium-emphasis">
|
||||
{{ tm("detail.changelogEmpty") }}
|
||||
</div>
|
||||
<div v-else class="docs-markdown" v-html="renderedChangelog"></div>
|
||||
<div
|
||||
v-else
|
||||
class="docs-markdown"
|
||||
v-html="renderedChangelog"
|
||||
@click="handleDocsClick"
|
||||
></div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</section>
|
||||
@@ -1325,6 +1392,7 @@ onBeforeUnmount(() => {
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin: 1.4em 0 0.6em;
|
||||
scroll-margin-top: calc(var(--v-layout-top, 64px) + 24px);
|
||||
}
|
||||
|
||||
.docs-markdown :deep(h1:first-child),
|
||||
|
||||
Reference in New Issue
Block a user