@sjolystinnovation/app-kit 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui` and `session/react` are styled with Tailwind CSS.
31
+ The components in `@sjolystinnovation/app-kit/ui`, `markdown` and `session/react` are styled with Tailwind CSS.
32
32
  Import the package's stylesheet after Tailwind:
33
33
 
34
34
  ```css
@@ -348,6 +348,36 @@ The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(
348
348
  `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
349
349
  rendered page dynamic.
350
350
 
351
+ ### `@sjolystinnovation/app-kit/markdown`
352
+
353
+ Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes. Raw HTML is
354
+ never rendered and the output is sanitized, so the text cannot inject scripts, styles, event
355
+ handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
356
+ `remark-gfm` and `rehype-sanitize`, which are optional peer dependencies:
357
+
358
+ ```sh
359
+ npm install react-markdown remark-gfm rehype-sanitize
360
+ ```
361
+
362
+ ```tsx
363
+ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
364
+
365
+ <div className="space-y-4 text-sm text-gray-700">
366
+ <Markdown>{build.description}</Markdown>
367
+ </div>;
368
+ ```
369
+
370
+ | Prop | Type | Default | Description |
371
+ | --- | --- | --- | --- |
372
+ | `children` | `string \| null \| undefined` | | The Markdown. Empty, null or undefined renders nothing. |
373
+ | `components` | `Components` | | Replaces the element for a tag, such as `{ p: MyParagraph }`. Tags not named keep the default. It gets the props without `node`, so it can spread them onto an element. |
374
+ | `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
375
+ | `className` | `string` | | Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. |
376
+
377
+ The text takes its colour and size from the parent. A level one heading becomes a level two, since
378
+ the page has its own `h1`. `markdownComponents` holds the default elements, for an app that builds
379
+ on them.
380
+
351
381
  ### `@sjolystinnovation/app-kit/session`
352
382
 
353
383
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -836,6 +866,11 @@ selector such as `.dark` for a scoped theme:
836
866
  | `--color-state-code` | gray 900, the code of a `NotFoundState` |
837
867
  | `--font-state-code` | `inherit`, the font of that code |
838
868
  | `--color-state-link`, `-hover` | gray 900, the home link |
869
+ | `--color-markdown-heading`, `-strong` | gray 900, headings and bold text in `Markdown` |
870
+ | `--color-markdown-link` | gray 900 |
871
+ | `--color-markdown-link-hover` | gray 600 |
872
+ | `--color-markdown-border` | gray 200, rules, quotes and table lines |
873
+ | `--color-markdown-code-bg`, `-code-text` | gray 100, gray 900 |
839
874
 
840
875
  A default that points at another variable is read once for the whole page. The focus ring and the
841
876
  primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -26,6 +26,7 @@
26
26
  "./toast": "./src/toast/index.ts",
27
27
  "./validation": "./src/validation/index.ts",
28
28
  "./next-config": "./src/next-config/index.ts",
29
+ "./markdown": "./src/markdown/index.ts",
29
30
  "./styles.css": "./src/styles.css"
30
31
  },
31
32
  "files": [
@@ -46,6 +47,9 @@
46
47
  "next-auth": "^4.24",
47
48
  "react": "^19",
48
49
  "react-dom": "^19",
50
+ "react-markdown": "^10",
51
+ "rehype-sanitize": "^6",
52
+ "remark-gfm": "^4",
49
53
  "sonner": "^2",
50
54
  "zod": "^4"
51
55
  },
@@ -55,6 +59,15 @@
55
59
  },
56
60
  "zod": {
57
61
  "optional": true
62
+ },
63
+ "react-markdown": {
64
+ "optional": true
65
+ },
66
+ "rehype-sanitize": {
67
+ "optional": true
68
+ },
69
+ "remark-gfm": {
70
+ "optional": true
58
71
  }
59
72
  },
60
73
  "devDependencies": {
@@ -71,6 +84,9 @@
71
84
  "next-auth": "^4.24.15",
72
85
  "react": "^19.3.0",
73
86
  "react-dom": "^19.3.0",
87
+ "react-markdown": "^10.1.0",
88
+ "rehype-sanitize": "^6.0.0",
89
+ "remark-gfm": "^4.0.1",
74
90
  "sonner": "^2.0.8",
75
91
  "typescript": "^6.0.3",
76
92
  "vitest": "^5.0.3",
@@ -85,7 +101,7 @@
85
101
  "tailwindcss"
86
102
  ],
87
103
  "dependencies": {
88
- "@types/jsonwebtoken": "^9.0.8",
89
- "jsonwebtoken": "^9.0.2"
104
+ "@types/jsonwebtoken": "^9.0.10",
105
+ "jsonwebtoken": "^9.0.3"
90
106
  }
91
107
  }
@@ -0,0 +1,89 @@
1
+ import type { ComponentPropsWithoutRef, ElementType } from "react";
2
+ import ReactMarkdown, { type Components, type ExtraProps } from "react-markdown";
3
+ import rehypeSanitize from "rehype-sanitize";
4
+ import remarkGfm from "remark-gfm";
5
+
6
+ type Tag = keyof React.JSX.IntrinsicElements;
7
+
8
+ /** The props without the `node` react-markdown passes, which is no DOM attribute. */
9
+ function domProps<P extends ExtraProps>(props: P): Omit<P, "node"> {
10
+ const rest = { ...props };
11
+ delete rest.node;
12
+ return rest;
13
+ }
14
+
15
+ /** An app's element, given the props without `node`, so it can spread them onto a DOM element. */
16
+ function withoutNode(Element: ElementType) {
17
+ const Wrapped = (props: ExtraProps) => <Element {...domProps(props)} />;
18
+ return Wrapped;
19
+ }
20
+
21
+ /** An element with fixed classes. */
22
+ function styled<T extends Tag>(Element: T, className: string) {
23
+ const Styled = (props: ComponentPropsWithoutRef<T> & ExtraProps) => {
24
+ const Component = Element as ElementType;
25
+ return <Component className={className} {...domProps(props)} />;
26
+ };
27
+ return Styled;
28
+ }
29
+
30
+ /** The default look. A heading level one becomes a level two, since the page has its own `h1`. */
31
+ export const markdownComponents: Components = {
32
+ h1: styled("h2", "text-lg font-semibold text-markdown-heading"),
33
+ h2: styled("h2", "text-base font-semibold text-markdown-heading pt-2"),
34
+ h3: styled("h3", "text-sm font-semibold text-markdown-heading"),
35
+ p: styled("p", "leading-relaxed"),
36
+ ul: styled("ul", "list-disc list-outside pl-5 space-y-1"),
37
+ ol: styled("ol", "list-decimal list-outside pl-5 space-y-1"),
38
+ li: styled("li", "leading-relaxed"),
39
+ a: styled("a", "font-medium text-markdown-link underline hover:text-markdown-link-hover"),
40
+ strong: styled("strong", "font-semibold text-markdown-strong"),
41
+ hr: styled("hr", "border-markdown-border"),
42
+ blockquote: styled("blockquote", "border-l-2 border-markdown-border pl-4 italic"),
43
+ code: styled("code", "rounded bg-markdown-code-bg px-1 py-0.5 text-xs font-mono text-markdown-code-text"),
44
+ table: props => (
45
+ <div className="overflow-x-auto">
46
+ <table className="w-full text-xs border-collapse" {...domProps(props)} />
47
+ </div>
48
+ ),
49
+ thead: styled("thead", "border-b border-markdown-border"),
50
+ tbody: styled("tbody", "divide-y divide-markdown-border"),
51
+ th: styled("th", "text-left py-2 pr-4 font-semibold text-markdown-heading"),
52
+ td: styled("td", "py-2 pr-4 align-top"),
53
+ };
54
+
55
+ const isExternal = (href: string | undefined) => href != null && /^https?:\/\//i.test(href);
56
+
57
+ export interface MarkdownProps {
58
+ /** Markdown text. Empty, null or undefined renders nothing. */
59
+ children: string | null | undefined;
60
+ /** Replaces the default element for a tag, such as `{ p: MyParagraph }`. It gets the props without `node`. */
61
+ components?: Components | undefined;
62
+ /** Opens links to other sites in a new tab, without passing the opener or referrer. */
63
+ externalLinks?: boolean | undefined;
64
+ /** Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. */
65
+ className?: string | undefined;
66
+ }
67
+
68
+ /**
69
+ * Markdown from an editor or the API, with GitHub tables, lists and links. Raw HTML is never
70
+ * rendered, and the output is sanitized, so the text cannot inject scripts, styles or event handlers.
71
+ */
72
+ export function Markdown({ children, components, externalLinks = false, className }: MarkdownProps) {
73
+ if (!children) return null;
74
+
75
+ const own = Object.entries(components ?? {}).map(([tag, Element]) => [tag, withoutNode(Element as ElementType)]);
76
+ const merged: Components = { ...markdownComponents, ...Object.fromEntries(own) };
77
+ if (externalLinks) {
78
+ const Link = (merged.a ?? "a") as ElementType;
79
+ merged.a = props =>
80
+ isExternal(props.href) ? <Link {...props} target="_blank" rel="noopener noreferrer" /> : <Link {...props} />;
81
+ }
82
+
83
+ const output = (
84
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]} components={merged}>
85
+ {children}
86
+ </ReactMarkdown>
87
+ );
88
+ return className === undefined ? output : <div className={className}>{output}</div>;
89
+ }
@@ -0,0 +1 @@
1
+ export { Markdown, markdownComponents, type MarkdownProps } from "./Markdown";
package/src/styles.css CHANGED
@@ -52,6 +52,14 @@
52
52
  --color-state-link: var(--color-gray-900);
53
53
  --color-state-link-hover: var(--color-gray-900);
54
54
 
55
+ --color-markdown-heading: var(--color-gray-900);
56
+ --color-markdown-strong: var(--color-gray-900);
57
+ --color-markdown-link: var(--color-gray-900);
58
+ --color-markdown-link-hover: var(--color-gray-600);
59
+ --color-markdown-border: var(--color-gray-200);
60
+ --color-markdown-code-bg: var(--color-gray-100);
61
+ --color-markdown-code-text: var(--color-gray-900);
62
+
55
63
  --radius-button: var(--radius-lg);
56
64
  --color-button-primary-bg: var(--color-primary);
57
65
  --color-button-primary-text: var(--color-primary-foreground);