84 lines2,976 bytesCodeBlame
1/**
2 * The renderer for the policies, which are written in Markdown under
3 * app/content/policies so that counsel can read and mark them up as plain
4 * text. The frame of a trust page is in trust-page.tsx.
5 */
6import { type ReactNode, isValidElement } from "react";
7import ReactMarkdown from "react-markdown";
8import { Link } from "react-router";
9import remarkGfm from "remark-gfm";
10
11function textOf(node: ReactNode): string {
12 if (node == null || typeof node === "boolean") return "";
13 if (typeof node === "string" || typeof node === "number") return String(node);
14 if (Array.isArray(node)) return node.map(textOf).join("");
15 if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);
16 return "";
17}
18
19/** A heading's anchor: "8. Paying for g1t" is `paying-for-g1t`. */
20export function anchor(children: ReactNode): string {
21 return textOf(children)
22 .toLowerCase()
23 .replace(/^\d+\.\s*/, "")
24 .replace(/[^a-z0-9]+/g, "-")
25 .replace(/^-|-$/g, "");
26}
27
28/** The headings of a policy, for its contents list. */
29export function outline(markdown: string): { id: string; title: string }[] {
30 return [...markdown.matchAll(/^## (.+)$/gm)].map((match) => ({ id: anchor(match[1]), title: match[1] }));
31}
32
33export { TrustPage } from "./trust-page";
34
35/** A Markdown policy. Links to g1t stay in the app; others are plain. */
36export function PolicyText({ source }: { source: string }) {
37 return (
38 <div className="prose max-w-3xl">
39 <ReactMarkdown
40 remarkPlugins={[remarkGfm]}
41 components={{
42 h2: ({ children }) => (
43 <h2 id={anchor(children)} className="scroll-mt-20">
44 {children}
45 </h2>
46 ),
47 h3: ({ children }) => (
48 <h3 id={anchor(children)} className="scroll-mt-20">
49 {children}
50 </h3>
51 ),
52 a: ({ href = "", children }) =>
53 href.startsWith("/") ? <Link to={href}>{children}</Link> : <a href={href}>{children}</a>,
54 table: ({ children }) => (
55 <div className="overflow-x-auto">
56 <table>{children}</table>
57 </div>
58 ),
59 }}
60 >
61 {source}
62 </ReactMarkdown>
63 </div>
64 );
65}
66
67/** "On this page", for the side of a long page. */
68export function Contents({ items }: { items: { id: string; title: string }[] }) {
69 if (items.length < 3) return null;
70 return (
71 <nav aria-label="On this page" className="lg:sticky lg:top-20">
72 <p className="text-xs font-medium tracking-wider text-faint uppercase">On this page</p>
73 <ol className="mt-3 space-y-1.5 border-l border-line text-sm">
74 {items.map((item) => (
75 <li key={item.id}>
76 <a href={`#${item.id}`} className="-ml-px block border-l border-transparent pl-3 text-muted hover:border-line-strong hover:text-fg">
77 {item.title.replace(/^\d+\.\s*/, "")}
78 </a>
79 </li>
80 ))}
81 </ol>
82 </nav>
83 );
84}