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:
一张白纸
2026-10-03 15:36:37 +08:00
committed by GitHub
parent 44b5c1bd9b
commit 95af4358bf
@@ -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),