Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/components/ipa/Guideline/Guideline.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
}
14 changes: 13 additions & 1 deletion src/components/ipa/Guideline/Guideline.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,13 +17,24 @@ const minimalGuideline = {
} satisfies GuidelineData;

describe("<Guideline> standalone", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders as a standalone block without a numbering circle", () => {
render(<Guideline {...minimalGuideline}>content</Guideline>);
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(<Guideline {...minimalGuideline}>content</Guideline>);
expect(collectAnchor).toHaveBeenCalledWith("IPA-001-must-test-a");
});
});
35 changes: 25 additions & 10 deletions src/components/ipa/Guideline/GuidelineFooter.tsx
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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;
Expand All @@ -46,13 +61,13 @@ export function GuidelineFooter(): ReactElement | null {
<span className={styles.label}>Depends on</span>
<div className={styles.deps}>
{guideline.dependsOn.map((depId) => (
<a
<Link
key={depId}
href={depHref(depId, principle.id)}
to={dependencyHref(depId, principle.id)}
className={styles.depTag}
>
{depLabel(depId)}
</a>
</Link>
))}
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/components/ipa/Guideline/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -18,12 +19,14 @@ function GuidelineBase({
...guideline
}: GuidelineProps): ReactElement {
const isInsideGuidelines = useIsInsideGuidelines();
useBrokenLinks().collectAnchor(guideline.id);
const Root = isInsideGuidelines ? "li" : "div";

return (
<GuidelineContext.Provider value={{ guideline }}>
<Root
className={clsx(styles.root, isInsideGuidelines && styles.numbered)}
id={guideline.id}
data-guideline-id={guideline.id}
>
{isInsideGuidelines && <NumberCircle />}
Expand Down
7 changes: 3 additions & 4 deletions src/components/ipa/Guidelines/Guidelines.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@
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);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
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). */
Expand Down
3 changes: 3 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
12 changes: 12 additions & 0 deletions vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));