Skip to content

Commit 7c7939d

Browse files
author
gbena-afk
committed
a11y: add per-page metadata descriptions and explicit html dir
1 parent f5b98f6 commit 7c7939d

20 files changed

Lines changed: 775 additions & 586 deletions

File tree

src/__tests__/metadata.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import fs from "fs";
2+
import path from "path";
3+
4+
import { metadata as layoutMeta } from "@/app/layout";
5+
import { metadata as aboutMeta } from "@/app/about/page";
6+
import { metadata as docsMeta } from "@/app/docs/page";
7+
import { metadata as pairsMeta } from "@/app/pairs/page";
8+
import { metadata as quoteMeta } from "@/app/quote/page";
9+
import { metadata as settingsMeta } from "@/app/settings/page";
10+
11+
describe("Metadata exports", () => {
12+
it("layout defines a template title", () => {
13+
expect(layoutMeta).toBeTruthy();
14+
// @ts-ignore - title may be object or string
15+
expect(layoutMeta.title.default).toBe("StableRoute");
16+
// @ts-ignore
17+
expect(layoutMeta.title.template).toBe("%s — StableRoute");
18+
});
19+
20+
it("about and docs include descriptions", () => {
21+
expect(typeof aboutMeta.title).toBe("string");
22+
// @ts-ignore
23+
expect(typeof aboutMeta.description).toBe("string");
24+
25+
expect(typeof docsMeta.title).toBe("string");
26+
// @ts-ignore
27+
expect(typeof docsMeta.description).toBe("string");
28+
});
29+
30+
it("client routes expose per-page metadata (title + description)", () => {
31+
expect(typeof pairsMeta.title).toBe("string");
32+
// @ts-ignore
33+
expect(typeof pairsMeta.description).toBe("string");
34+
35+
expect(typeof quoteMeta.title).toBe("string");
36+
// @ts-ignore
37+
expect(typeof quoteMeta.description).toBe("string");
38+
39+
expect(typeof settingsMeta.title).toBe("string");
40+
// @ts-ignore
41+
expect(typeof settingsMeta.description).toBe("string");
42+
});
43+
44+
it("layout.tsx contains explicit dir attribute on <html>", () => {
45+
const layoutPath = path.join(process.cwd(), "src/app/layout.tsx");
46+
const src = fs.readFileSync(layoutPath, "utf8");
47+
expect(src.includes('dir="ltr"')).toBe(true);
48+
});
49+
});

src/app/about/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const metadata = { title: "About — StableRoute" };
1+
export const metadata = { title: "About — StableRoute", description: "About StableRoute: a liquidity router for stablecoin and fiat-backed token pairs on Stellar." };
22

33
export default function AboutPage() {
44
return (

src/app/admin/Client.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { apiGet, apiPost } from "@/lib/apiClient";
5+
6+
export default function AdminClient() {
7+
const [paused, setPaused] = useState<boolean | null>(null);
8+
const [error, setError] = useState<string | null>(null);
9+
10+
const load = () =>
11+
apiGet<{ paused: boolean }>("/api/v1/admin/status")
12+
.then((b) => setPaused(b.paused))
13+
.catch((e) => setError(e.message));
14+
useEffect(() => {
15+
load();
16+
}, []);
17+
18+
const toggle = async () => {
19+
setError(null);
20+
try {
21+
await apiPost(paused ? "/api/v1/admin/unpause" : "/api/v1/admin/pause", {});
22+
await load();
23+
} catch (err) {
24+
setError((err as Error).message);
25+
}
26+
};
27+
28+
return (
29+
<main
30+
id="main-content"
31+
tabIndex={-1}
32+
className="mx-auto flex min-h-[60vh] max-w-xl flex-col gap-6 p-8 focus:outline-none"
33+
>
34+
<h1 className="text-3xl font-semibold tracking-tight">Admin</h1>
35+
{paused === null && !error && <p>Loading status…</p>}
36+
{paused !== null && (
37+
<section className="flex items-center justify-between rounded-lg border border-neutral-200 p-4 dark:border-neutral-800">
38+
<p>
39+
Status: <strong>{paused ? "Paused" : "Live"}</strong>
40+
</p>
41+
<button
42+
type="button"
43+
onClick={toggle}
44+
className="rounded-full bg-black px-5 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
45+
>
46+
{paused ? "Unpause" : "Pause"}
47+
</button>
48+
</section>
49+
)}
50+
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
51+
</main>
52+
);
53+
}

src/app/admin/page.tsx

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,11 @@
1-
"use client";
1+
import AdminClient from "./Client";
22

3-
import { useEffect, useState } from "react";
4-
import { apiGet, apiPost } from "@/lib/apiClient";
3+
/** Server wrapper for `AdminClient`. */
4+
export const metadata = {
5+
title: "Admin",
6+
description: "Operator controls: pause and unpause the StableRoute router and view status.",
7+
};
58

69
export default function AdminPage() {
7-
const [paused, setPaused] = useState<boolean | null>(null);
8-
const [error, setError] = useState<string | null>(null);
9-
10-
const load = () =>
11-
apiGet<{ paused: boolean }>("/api/v1/admin/status")
12-
.then((b) => setPaused(b.paused))
13-
.catch((e) => setError(e.message));
14-
useEffect(() => {
15-
load();
16-
}, []);
17-
18-
const toggle = async () => {
19-
setError(null);
20-
try {
21-
await apiPost(paused ? "/api/v1/admin/unpause" : "/api/v1/admin/pause", {});
22-
await load();
23-
} catch (err) {
24-
setError((err as Error).message);
25-
}
26-
};
27-
28-
return (
29-
<main
30-
id="main-content"
31-
tabIndex={-1}
32-
className="mx-auto flex min-h-[60vh] max-w-xl flex-col gap-6 p-8 focus:outline-none"
33-
>
34-
<h1 className="text-3xl font-semibold tracking-tight">Admin</h1>
35-
{paused === null && !error && <p>Loading status…</p>}
36-
{paused !== null && (
37-
<section className="flex items-center justify-between rounded-lg border border-neutral-200 p-4 dark:border-neutral-800">
38-
<p>
39-
Status: <strong>{paused ? "Paused" : "Live"}</strong>
40-
</p>
41-
<button
42-
type="button"
43-
onClick={toggle}
44-
className="rounded-full bg-black px-5 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
45-
>
46-
{paused ? "Unpause" : "Pause"}
47-
</button>
48-
</section>
49-
)}
50-
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
51-
</main>
52-
);
10+
return <AdminClient />;
5311
}

src/app/api-keys/Client.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { apiGet, apiPost, apiDelete } from "@/lib/apiClient";
5+
6+
type Item = { prefix: string; label: string; createdAt: number };
7+
8+
export default function ApiKeysClient() {
9+
const [items, setItems] = useState<Item[] | null>(null);
10+
const [label, setLabel] = useState("");
11+
const [created, setCreated] = useState<string | null>(null);
12+
const [error, setError] = useState<string | null>(null);
13+
14+
const load = () =>
15+
apiGet<{ items: Item[] }>("/api/v1/api-keys")
16+
.then((b) => setItems(b.items))
17+
.catch((e) => setError(e.message));
18+
useEffect(() => {
19+
load();
20+
}, []);
21+
22+
const onCreate = async (e: React.FormEvent) => {
23+
e.preventDefault();
24+
setError(null);
25+
try {
26+
const r = await apiPost<{ key: string }>("/api/v1/api-keys", { label });
27+
setCreated(r.key);
28+
setLabel("");
29+
await load();
30+
} catch (err) {
31+
setError((err as Error).message);
32+
}
33+
};
34+
35+
return (
36+
<main
37+
id="main-content"
38+
tabIndex={-1}
39+
className="mx-auto flex min-h-[60vh] max-w-3xl flex-col gap-6 p-8 focus:outline-none"
40+
>
41+
<h1 className="text-3xl font-semibold tracking-tight">API keys</h1>
42+
<form onSubmit={onCreate} className="flex gap-2">
43+
<input
44+
required
45+
maxLength={64}
46+
aria-label="Label"
47+
value={label}
48+
onChange={(e) => setLabel(e.target.value)}
49+
placeholder="Label"
50+
className="flex-1 rounded-md border border-neutral-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
51+
/>
52+
<button
53+
type="submit"
54+
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
55+
>
56+
Create
57+
</button>
58+
</form>
59+
{created && (
60+
<div role="status" className="rounded border border-emerald-300 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950">
61+
<p className="font-medium">Copy now — shown only once:</p>
62+
<code className="break-all">{created}</code>
63+
</div>
64+
)}
65+
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
66+
{!items && !error && <p>Loading…</p>}
67+
<section aria-live="polite" aria-atomic="true" className="contents">
68+
{items && items.length === 0 && (
69+
<p className="text-sm text-neutral-600 dark:text-neutral-400">No API keys yet.</p>
70+
)}
71+
{items && items.length > 0 && (
72+
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
73+
{items.map((k) => (
74+
<li key={k.prefix} className="flex items-center justify-between py-3">
75+
<div>
76+
<p className="text-sm font-medium">{k.label}</p>
77+
<p className="font-mono text-xs text-neutral-500">{k.prefix}</p>
78+
</div>
79+
<button
80+
type="button"
81+
onClick={() => apiDelete(`/api/v1/api-keys/${k.prefix}`).then(() => load())}
82+
className="rounded border border-neutral-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700"
83+
>
84+
Revoke
85+
</button>
86+
</li>
87+
))}
88+
</ul>
89+
)}
90+
</section>
91+
</main>
92+
);
93+
}

src/app/api-keys/page.tsx

Lines changed: 7 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,11 @@
1-
"use client";
1+
import ApiKeysClient from "./Client";
22

3-
import { useEffect, useState } from "react";
4-
import { apiGet, apiPost, apiDelete } from "@/lib/apiClient";
5-
6-
type Item = { prefix: string; label: string; createdAt: number };
3+
/** Server wrapper for `ApiKeysClient`. */
4+
export const metadata = {
5+
title: "API keys",
6+
description: "Create, view and revoke API keys for programmatic access to StableRoute.",
7+
};
78

89
export default function ApiKeysPage() {
9-
const [items, setItems] = useState<Item[] | null>(null);
10-
const [label, setLabel] = useState("");
11-
const [created, setCreated] = useState<string | null>(null);
12-
const [error, setError] = useState<string | null>(null);
13-
14-
const load = () =>
15-
apiGet<{ items: Item[] }>("/api/v1/api-keys")
16-
.then((b) => setItems(b.items))
17-
.catch((e) => setError(e.message));
18-
useEffect(() => {
19-
load();
20-
}, []);
21-
22-
const onCreate = async (e: React.FormEvent) => {
23-
e.preventDefault();
24-
setError(null);
25-
try {
26-
const r = await apiPost<{ key: string }>("/api/v1/api-keys", { label });
27-
setCreated(r.key);
28-
setLabel("");
29-
await load();
30-
} catch (err) {
31-
setError((err as Error).message);
32-
}
33-
};
34-
35-
return (
36-
<main
37-
id="main-content"
38-
tabIndex={-1}
39-
className="mx-auto flex min-h-[60vh] max-w-3xl flex-col gap-6 p-8 focus:outline-none"
40-
>
41-
<h1 className="text-3xl font-semibold tracking-tight">API keys</h1>
42-
<form onSubmit={onCreate} className="flex gap-2">
43-
<input
44-
required
45-
maxLength={64}
46-
aria-label="Label"
47-
value={label}
48-
onChange={(e) => setLabel(e.target.value)}
49-
placeholder="Label"
50-
className="flex-1 rounded-md border border-neutral-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
51-
/>
52-
<button
53-
type="submit"
54-
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
55-
>
56-
Create
57-
</button>
58-
</form>
59-
{created && (
60-
<div role="status" className="rounded border border-emerald-300 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950">
61-
<p className="font-medium">Copy now — shown only once:</p>
62-
<code className="break-all">{created}</code>
63-
</div>
64-
)}
65-
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
66-
{!items && !error && <p>Loading…</p>}
67-
<section aria-live="polite" aria-atomic="true" className="contents">
68-
{items && items.length === 0 && (
69-
<p className="text-sm text-neutral-600 dark:text-neutral-400">No API keys yet.</p>
70-
)}
71-
{items && items.length > 0 && (
72-
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
73-
{items.map((k) => (
74-
<li key={k.prefix} className="flex items-center justify-between py-3">
75-
<div>
76-
<p className="text-sm font-medium">{k.label}</p>
77-
<p className="font-mono text-xs text-neutral-500">{k.prefix}</p>
78-
</div>
79-
<button
80-
type="button"
81-
onClick={() => apiDelete(`/api/v1/api-keys/${k.prefix}`).then(() => load())}
82-
className="rounded border border-neutral-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700"
83-
>
84-
Revoke
85-
</button>
86-
</li>
87-
))}
88-
</ul>
89-
)}
90-
</section>
91-
</main>
92-
);
10+
return <ApiKeysClient />;
9311
}

src/app/docs/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
export const metadata = { title: "Docs — StableRoute" };
1+
export const metadata = {
2+
title: "Docs — StableRoute",
3+
description: "Short reference for the StableRoute HTTP API common endpoints.",
4+
};
25

36
const sections = [
47
{

0 commit comments

Comments
 (0)