Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/zettel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
"react-router-dom": "catalog:",
"remark-gfm": "^4.0.1",
"zod": "catalog:"
},
Expand Down
4 changes: 4 additions & 0 deletions apps/zettel/src/frontend/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export const api = {
graph: {
$get: () => authedFetch("/api/graph"),
},
wiki: {
$get: (opts: { param: { entity: string } }) =>
authedFetch(`/api/wiki/${encodeURIComponent(opts.param.entity)}`),
},
transcribe: {
$post: (opts: { form: Record<string, unknown> }) => {
const formData = new FormData();
Expand Down
53 changes: 53 additions & 0 deletions apps/zettel/src/frontend/components/TopicPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @vitest-environment jsdom

import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { getTopic } = vi.hoisted(() => ({ getTopic: vi.fn() }));

vi.mock("../api-client", () => ({
api: { wiki: { $get: getTopic } },
}));

import TopicPage from "./TopicPage";

function renderTopic(path = "/wiki/Ada") {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/wiki/:entity" element={<TopicPage />} />
</Routes>
</MemoryRouter>,
);
}

describe("TopicPage", () => {
beforeEach(() => {
getTopic.mockReset();
});

it("loads the authenticated topic API and renders wikilinks as routes", async () => {
getTopic.mockResolvedValue({
ok: true,
json: async () => ({ markdown: "# Ada\n\nRelated to [[Analytical Engine]]." }),
});

renderTopic();

expect(await screen.findByRole("heading", { name: "Ada" })).toBeInTheDocument();
expect(getTopic).toHaveBeenCalledWith({ param: { entity: "Ada" } });
expect(screen.getByRole("link", { name: "Analytical Engine" })).toHaveAttribute(
"href",
"/wiki/Analytical%20Engine",
);
});

it("shows an API failure", async () => {
getTopic.mockResolvedValue({ ok: false, json: async () => ({ error: "not available" }) });

renderTopic();

expect(await screen.findByRole("alert")).toHaveTextContent("not available");
});
});
68 changes: 68 additions & 0 deletions apps/zettel/src/frontend/components/TopicPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "../api-client";

function wikilinks(markdown: string): string {
return markdown.replace(/\[\[([^\]]+)\]\]/g, (_match, label: string) => {
return `[${label}](/wiki/${encodeURIComponent(label)})`;
});
}

export default function TopicPage() {
const { entity = "" } = useParams<{ entity: string }>();
const [markdown, setMarkdown] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);

useEffect(() => {
let active = true;
setLoading(true);
setError("");
void api.wiki
.$get({ param: { entity } })
.then(async (response) => {
const data = await response.json();
if (!response.ok || "error" in data) {
throw new Error("error" in data ? data.error : "Failed to load topic");
}
if (active) setMarkdown(data.markdown);
})
.catch((err: unknown) => {
if (active) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [entity]);

if (loading) return <main aria-busy="true">Loading topic…</main>;
if (error) return <main role="alert">Error: {error}</main>;

return (
<main style={{ maxWidth: 800, margin: "0 auto", padding: 40 }}>
<nav>
<Link to="/">← Back to Zettel</Link>
</nav>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...props }) =>
href?.startsWith("/wiki/") ? (
<Link to={href}>{children}</Link>
) : (
<a href={href} {...props}>
{children}
</a>
),
}}
>
{wikilinks(markdown)}
</ReactMarkdown>
</main>
);
}
9 changes: 8 additions & 1 deletion apps/zettel/src/frontend/main.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import App from "./App.tsx";
import TopicPage from "./components/TopicPage.tsx";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<BrowserRouter basename="/zettel">
<Routes>
<Route path="/" element={<App />} />
<Route path="/wiki/:entity" element={<TopicPage />} />
</Routes>
</BrowserRouter>
</StrictMode>,
);
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ catalog:
"@libsql/kysely-libsql": 0.4.1
react: 19.2.7
react-dom: 19.2.7
react-router-dom: 7.18.0
tsx: 4.22.4
typescript: 7.0.1-rc
vite: 8.0.16
Expand Down
Loading