196 lines
5.4 KiB
TypeScript
196 lines
5.4 KiB
TypeScript
|
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||
|
|
import remarkGfm from "remark-gfm";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Server-rendered markdown for SOPs and the SOP README.
|
||
|
|
*
|
||
|
|
* The WFDASH app doesn't depend on `@tailwindcss/typography`, so we
|
||
|
|
* hand-style the elements that show up in the SOPs (headings,
|
||
|
|
* paragraphs, lists, tables, code blocks, blockquotes, hr).
|
||
|
|
*
|
||
|
|
* The `components` map below is the single source of truth for those
|
||
|
|
* styles. If a new markdown shape shows up in an SOP, add a class
|
||
|
|
* here rather than introducing the typography plugin.
|
||
|
|
*/
|
||
|
|
export function Markdown({ children }: { children: string }) {
|
||
|
|
return (
|
||
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||
|
|
{children}
|
||
|
|
</ReactMarkdown>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const components: Components = {
|
||
|
|
h1: ({ children, ...props }) => (
|
||
|
|
<h1
|
||
|
|
className="text-2xl font-semibold tracking-tight font-heading-theme mt-6 mb-3 first:mt-0"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</h1>
|
||
|
|
),
|
||
|
|
h2: ({ children, ...props }) => (
|
||
|
|
<h2
|
||
|
|
className="text-lg font-semibold tracking-tight mt-8 mb-3 pb-2 border-b"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</h2>
|
||
|
|
),
|
||
|
|
h3: ({ children, ...props }) => (
|
||
|
|
<h3 className="text-base font-semibold mt-6 mb-2" {...props}>
|
||
|
|
{children}
|
||
|
|
</h3>
|
||
|
|
),
|
||
|
|
h4: ({ children, ...props }) => (
|
||
|
|
<h4 className="text-sm font-semibold mt-4 mb-1.5" {...props}>
|
||
|
|
{children}
|
||
|
|
</h4>
|
||
|
|
),
|
||
|
|
p: ({ children, ...props }) => (
|
||
|
|
<p className="text-sm leading-relaxed my-3" {...props}>
|
||
|
|
{children}
|
||
|
|
</p>
|
||
|
|
),
|
||
|
|
ul: ({ children, ...props }) => (
|
||
|
|
<ul
|
||
|
|
className="text-sm leading-relaxed my-3 ml-6 list-disc space-y-1.5"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</ul>
|
||
|
|
),
|
||
|
|
ol: ({ children, ...props }) => (
|
||
|
|
<ol
|
||
|
|
className="text-sm leading-relaxed my-3 ml-6 list-decimal space-y-1.5"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</ol>
|
||
|
|
),
|
||
|
|
li: ({ children, ...props }) => (
|
||
|
|
<li className="pl-0.5" {...props}>
|
||
|
|
{children}
|
||
|
|
</li>
|
||
|
|
),
|
||
|
|
a: ({ children, href, ...props }) => (
|
||
|
|
<a
|
||
|
|
href={href}
|
||
|
|
className="text-primary underline underline-offset-2 hover:no-underline"
|
||
|
|
target={href?.startsWith("http") ? "_blank" : undefined}
|
||
|
|
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</a>
|
||
|
|
),
|
||
|
|
blockquote: ({ children, ...props }) => (
|
||
|
|
<blockquote
|
||
|
|
className="my-4 border-l-4 border-muted-foreground/30 bg-muted/40 px-4 py-2 text-sm italic text-muted-foreground"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</blockquote>
|
||
|
|
),
|
||
|
|
hr: (props) => (
|
||
|
|
<hr className="my-6 border-border" {...props} />
|
||
|
|
),
|
||
|
|
// GFM tables. The Markdown plugin emits these as a `table` element
|
||
|
|
// with thead/tbody/tr/th/td children. Wrap in a scrollable container
|
||
|
|
// so wide KPI tables don't blow out the layout.
|
||
|
|
table: ({ children, ...props }) => (
|
||
|
|
<div className="my-4 overflow-x-auto rounded-md border">
|
||
|
|
<table className="w-full text-sm" {...props}>
|
||
|
|
{children}
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
thead: ({ children, ...props }) => (
|
||
|
|
<thead className="bg-muted/50" {...props}>
|
||
|
|
{children}
|
||
|
|
</thead>
|
||
|
|
),
|
||
|
|
tbody: ({ children, ...props }) => (
|
||
|
|
<tbody className="divide-y" {...props}>
|
||
|
|
{children}
|
||
|
|
</tbody>
|
||
|
|
),
|
||
|
|
tr: ({ children, ...props }) => (
|
||
|
|
<tr {...props}>{children}</tr>
|
||
|
|
),
|
||
|
|
th: ({ children, ...props }) => (
|
||
|
|
<th
|
||
|
|
className="px-3 py-2 text-left font-medium text-foreground text-xs uppercase tracking-wider"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</th>
|
||
|
|
),
|
||
|
|
td: ({ children, ...props }) => (
|
||
|
|
<td
|
||
|
|
className="px-3 py-2 align-top text-sm text-foreground/90"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</td>
|
||
|
|
),
|
||
|
|
code: ({ children, className, ...props }) => {
|
||
|
|
// `react-markdown` puts fenced code blocks in a <pre><code> pair
|
||
|
|
// and inline code in a bare <code>. The presence of a `className`
|
||
|
|
// (set by `react-markdown` for fenced blocks, e.g. "language-ts")
|
||
|
|
// is the discriminator. For inline code we just style the chip.
|
||
|
|
const isBlock = typeof className === "string" && className.length > 0;
|
||
|
|
if (isBlock) {
|
||
|
|
return (
|
||
|
|
<code className={className} {...props}>
|
||
|
|
{children}
|
||
|
|
</code>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<code
|
||
|
|
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.85em] text-foreground/90"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</code>
|
||
|
|
);
|
||
|
|
},
|
||
|
|
pre: ({ children, ...props }) => (
|
||
|
|
<pre
|
||
|
|
className="my-4 overflow-x-auto rounded-md border bg-muted/30 p-3 text-xs leading-relaxed"
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</pre>
|
||
|
|
),
|
||
|
|
strong: ({ children, ...props }) => (
|
||
|
|
<strong className="font-semibold text-foreground" {...props}>
|
||
|
|
{children}
|
||
|
|
</strong>
|
||
|
|
),
|
||
|
|
em: ({ children, ...props }) => (
|
||
|
|
<em className="italic" {...props}>
|
||
|
|
{children}
|
||
|
|
</em>
|
||
|
|
),
|
||
|
|
input: ({ type, checked, disabled, ...props }) => {
|
||
|
|
// GFM task-list checkboxes render as disabled checkboxes. Just
|
||
|
|
// pass them through — we don't need a custom checkbox component,
|
||
|
|
// the default browser style is fine and conveys "completed task".
|
||
|
|
if (type === "checkbox") {
|
||
|
|
return (
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={checked}
|
||
|
|
disabled={disabled}
|
||
|
|
readOnly
|
||
|
|
className="mr-1.5 align-middle"
|
||
|
|
{...props}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return <input type={type} {...props} />;
|
||
|
|
},
|
||
|
|
};
|