diff --git a/src/components/ipa/Guideline/Guideline.module.css b/src/components/ipa/Guideline/Guideline.module.css index 228845c..d54729f 100644 --- a/src/components/ipa/Guideline/Guideline.module.css +++ b/src/components/ipa/Guideline/Guideline.module.css @@ -2,6 +2,11 @@ display: flex; align-items: flex-start; gap: 0.75rem; + scroll-margin-top: calc(var(--ifm-navbar-height) + 0.5rem); +} + +.root:target { + animation: guidelineTargetFlash 1.4s ease-out; } .numbered { @@ -17,3 +22,23 @@ font-size: 1rem; line-height: 1.625; } + +@keyframes guidelineTargetFlash { + 0% { + background-color: var(--ifm-color-primary-lightest); + box-shadow: 0 0 0 0.25rem var(--ifm-color-primary-lightest); + } + + 100% { + background-color: transparent; + box-shadow: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .root:target { + animation: none; + outline: 2px solid var(--ifm-color-primary); + outline-offset: 0.25rem; + } +} diff --git a/src/components/ipa/Guideline/Guideline.test.tsx b/src/components/ipa/Guideline/Guideline.test.tsx index e216ade..2828260 100644 --- a/src/components/ipa/Guideline/Guideline.test.tsx +++ b/src/components/ipa/Guideline/Guideline.test.tsx @@ -1,5 +1,6 @@ import { render } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import useBrokenLinks from "@docusaurus/useBrokenLinks"; import { Guideline } from "./index"; import type { Guideline as GuidelineData } from "../../../types/guideline"; @@ -16,13 +17,24 @@ const minimalGuideline = { } satisfies GuidelineData; describe(" standalone", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders as a standalone block without a numbering circle", () => { render(content); const guideline = document.querySelector("[data-guideline-id]"); expect(guideline?.tagName).toBe("DIV"); + expect(guideline).toHaveAttribute("id", "IPA-001-must-test-a"); expect( document.querySelector("[data-guideline-id] [aria-hidden='true']"), ).toBeNull(); }); + + it("registers the guideline anchor with the Docusaurus broken-link checker", () => { + const { collectAnchor } = vi.mocked(useBrokenLinks)(); + render(content); + expect(collectAnchor).toHaveBeenCalledWith("IPA-001-must-test-a"); + }); }); diff --git a/src/components/ipa/Guideline/GuidelineFooter.tsx b/src/components/ipa/Guideline/GuidelineFooter.tsx index da34c15..23cc80a 100644 --- a/src/components/ipa/Guideline/GuidelineFooter.tsx +++ b/src/components/ipa/Guideline/GuidelineFooter.tsx @@ -1,11 +1,12 @@ import type { ReactElement } from "react"; +import Link from "@docusaurus/Link"; import { useGuideline } from "../../../hooks/useGuideline"; import { usePrinciple } from "../../../hooks/usePrinciple"; import styles from "./GuidelineFooter.module.css"; -// IPA-107-must-use-http-patch → "Use HTTP Patch" +// IPA-107-must-use-http-patch → "Must Use HTTP Patch" function slugToTitle(id: string): string { - const slug = id.replace(/^IPA-\d{3}-(must|should|may)-/, ""); + const slug = id.split("-").slice(2).join("-"); return slug .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) @@ -14,22 +15,36 @@ function slugToTitle(id: string): string { // IPA-107-must-use-http-patch → 107 function ipaNumber(id: string): number | null { - const match = id.match(/^IPA-(\d{3})/); - return match ? parseInt(match[1], 10) : null; + const [prefix, number] = id.split("-"); + if (prefix !== "IPA") return null; + + const parsed = Number(number); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function hasGuidelineAnchor(id: string): boolean { + return id.split("-").length > 2; } -// "IPA-107-must-use-http-patch" → "IPA-107: Use HTTP Patch" +// "IPA-107-must-use-http-patch" → "IPA 107: Must Use HTTP Patch" +// "IPA-107" → "IPA 107" function depLabel(depId: string): string { const num = ipaNumber(depId); + if (!hasGuidelineAnchor(depId)) return num !== null ? `IPA ${num}` : depId; + const title = slugToTitle(depId); - return num !== null ? `IPA-${num}: ${title}` : title; + return num !== null ? `IPA ${num}: ${title}` : title; } // Cross-IPA: /107#IPA-107-must-use-http-patch // Same-IPA: #IPA-107-must-use-http-patch -function depHref(depId: string, currentIpa: number): string { +function dependencyHref(depId: string, currentIpa: number): string { const depIpa = ipaNumber(depId); + const hasAnchor = hasGuidelineAnchor(depId); const anchor = `#${depId}`; + if (!hasAnchor) + return depIpa !== null && depIpa !== currentIpa ? `/${depIpa}` : anchor; + return depIpa !== null && depIpa !== currentIpa ? `/${depIpa}${anchor}` : anchor; @@ -46,13 +61,13 @@ export function GuidelineFooter(): ReactElement | null { Depends on
{guideline.dependsOn.map((depId) => ( - {depLabel(depId)} - + ))}
diff --git a/src/components/ipa/Guideline/index.tsx b/src/components/ipa/Guideline/index.tsx index 003a70d..91e2ceb 100644 --- a/src/components/ipa/Guideline/index.tsx +++ b/src/components/ipa/Guideline/index.tsx @@ -1,4 +1,5 @@ import { type ReactNode, type ReactElement } from "react"; +import useBrokenLinks from "@docusaurus/useBrokenLinks"; import clsx from "clsx"; import type { Guideline as GuidelineData } from "../../../types/guideline"; import { GuidelineContext } from "../../../hooks/useGuideline"; @@ -18,12 +19,14 @@ function GuidelineBase({ ...guideline }: GuidelineProps): ReactElement { const isInsideGuidelines = useIsInsideGuidelines(); + useBrokenLinks().collectAnchor(guideline.id); const Root = isInsideGuidelines ? "li" : "div"; return ( {isInsideGuidelines && } diff --git a/src/components/ipa/Guidelines/Guidelines.module.css b/src/components/ipa/Guidelines/Guidelines.module.css index f35e107..8ef6e13 100644 --- a/src/components/ipa/Guidelines/Guidelines.module.css +++ b/src/components/ipa/Guidelines/Guidelines.module.css @@ -2,8 +2,7 @@ counter-reset: number-circle; list-style: none; margin: 2rem 0; - /* Horizontal padding only. */ - padding: 0 1.5rem; + padding: 0; border-radius: 0.5rem; border: 1px solid var(--ifm-color-emphasis-200); background-color: var(--ifm-card-background-color); @@ -11,9 +10,9 @@ overflow: hidden; } -/* Vertical padding per guideline. */ +/* Padding per guideline, so target highlights include the inset space. */ .root > * { - padding: 1.5rem 0; + padding: 1.5rem; } /* Separator, centered between guidelines (mirrors the infima list-item margin). */ diff --git a/vitest.config.ts b/vitest.config.ts index 286efcc..c59bb38 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ // Docusaurus aliases @site to the repo root; tests must match. alias: { "@site": fileURLToPath(new URL(".", import.meta.url)), + "@docusaurus/Link": "@docusaurus/core/lib/client/exports/Link", + "@docusaurus/useBrokenLinks": + "@docusaurus/core/lib/client/exports/useBrokenLinks", "@docusaurus/useDocusaurusContext": "@docusaurus/core/lib/client/exports/useDocusaurusContext", }, diff --git a/vitest.setup.ts b/vitest.setup.ts index 7f12baf..3ab88a4 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -7,3 +7,15 @@ // `@docusaurus/*` and need stubs at unit-test time. import "@testing-library/jest-dom/vitest"; +import { createElement } from "react"; +import { vi } from "vitest"; + +vi.mock("@docusaurus/Link", () => ({ + default: ({ to, href, children, ...props }: any) => + createElement("a", { href: to ?? href, ...props }, children), +})); + +const _brokenLinksMock = { collectAnchor: vi.fn(), collectLink: vi.fn() }; +vi.mock("@docusaurus/useBrokenLinks", () => ({ + default: vi.fn(() => _brokenLinksMock), +}));