diff --git a/package-lock.json b/package-lock.json
index 2c87eeace..724643fd3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -70,7 +70,10 @@
"react-dom": "^18",
"react-focus-lock": "^2.13.7",
"react-hook-form": "^7.71.2",
+ "react-markdown": "^9.1.0",
+ "rehype-sanitize": "^6.0.0",
"remark": "^15.0.1",
+ "remark-gfm": "^4.0.1",
"resend": "^6.9.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
@@ -11519,6 +11522,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-sanitize": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
+ "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "unist-util-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-estree": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz",
@@ -11623,6 +11641,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -15057,6 +15085,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -15066,6 +15104,34 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -15090,6 +15156,107 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdast-util-mdx": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz",
@@ -15342,6 +15509,127 @@
"micromark-util-types": "^2.0.0"
}
},
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/micromark-extension-mdx-expression": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz",
@@ -18055,6 +18343,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-markdown": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
+ "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -18325,6 +18640,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/rehype-sanitize": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
+ "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-sanitize": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark": {
"version": "15.0.1",
"resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz",
@@ -18341,6 +18670,24 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz",
diff --git a/package.json b/package.json
index 6e7f56d55..7cb01d7f7 100644
--- a/package.json
+++ b/package.json
@@ -155,7 +155,10 @@
"react-dom": "^18",
"react-focus-lock": "^2.13.7",
"react-hook-form": "^7.71.2",
+ "react-markdown": "^9.1.0",
+ "rehype-sanitize": "^6.0.0",
"remark": "^15.0.1",
+ "remark-gfm": "^4.0.1",
"resend": "^6.9.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
diff --git a/src/app/(public)/articles/[slug]/ArticleMarkdown.tsx b/src/app/(public)/articles/[slug]/ArticleMarkdown.tsx
new file mode 100644
index 000000000..2eb3e931f
--- /dev/null
+++ b/src/app/(public)/articles/[slug]/ArticleMarkdown.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+/**
+ * Renders user-authored markdown safely. react-markdown never executes JSX (so a
+ * stray `<` in a post can't break the page), rehype-sanitize strips dangerous
+ * HTML, and remark-gfm adds tables/strikethrough/task-lists. Every element maps
+ * to design tokens — no raw hex, no prose plugin — for x.ai/Substack-grade
+ * long-form typography that respects light and dark themes.
+ */
+
+import Link from 'next/link';
+import type { ComponentProps } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import rehypeSanitize from 'rehype-sanitize';
+
+const components = {
+ h1: (props: ComponentProps<'h1'>) => (
+
+ ),
+ h2: (props: ComponentProps<'h2'>) => (
+
+ ),
+ h3: (props: ComponentProps<'h3'>) => (
+
+ ),
+ p: (props: ComponentProps<'p'>) => (
+
+ ),
+ a: ({ href, ...props }: ComponentProps<'a'>) => (
+
+ ),
+ ul: (props: ComponentProps<'ul'>) => (
+
+ ),
+ ol: (props: ComponentProps<'ol'>) => (
+
+ ),
+ li: (props: ComponentProps<'li'>) => ,
+ blockquote: (props: ComponentProps<'blockquote'>) => (
+
+ ),
+ hr: () =>
,
+ img: ({ alt, ...props }: ComponentProps<'img'>) => (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ),
+ code: (props: ComponentProps<'code'>) => (
+
+ ),
+ pre: (props: ComponentProps<'pre'>) => (
+
+ ),
+ table: (props: ComponentProps<'table'>) => (
+
+ ),
+ th: (props: ComponentProps<'th'>) => (
+ |
+ ),
+ td: (props: ComponentProps<'td'>) => (
+ |
+ ),
+};
+
+export default function ArticleMarkdown({ body }: { body: string }) {
+ return (
+
+ {body}
+
+ );
+}
diff --git a/src/app/(public)/articles/[slug]/page.tsx b/src/app/(public)/articles/[slug]/page.tsx
new file mode 100644
index 000000000..5351b2309
--- /dev/null
+++ b/src/app/(public)/articles/[slug]/page.tsx
@@ -0,0 +1,158 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+import { ArrowLeft, Clock, Lock, Users } from 'lucide-react';
+import { getArticleBySlug } from '@/services/articles/get-article';
+import { ARTICLE_COPY } from '@/config/articles';
+import { ROUTES } from '@/config/routes';
+import { JsonLdScript } from '@/lib/seo/structured-data';
+import { APP_NAME, SITE_URL } from '@/config/brand';
+import ArticleMarkdown from './ArticleMarkdown';
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+}
+
+function profileHref(username: string | undefined, id: string): string {
+ return `/profiles/${username ?? id}`;
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { slug } = await params;
+ const article = await getArticleBySlug(slug);
+ if (!article) {
+ return { title: 'Article not found' };
+ }
+
+ const isIndexable = article.visibility === 'public';
+ return {
+ title: article.title,
+ description: article.excerpt,
+ robots: isIndexable ? undefined : { index: false, follow: false },
+ openGraph: {
+ title: article.title,
+ description: article.excerpt,
+ type: 'article',
+ publishedTime: article.publishedAt,
+ authors: [article.author.name],
+ images: article.coverImage ? [article.coverImage] : undefined,
+ url: `${SITE_URL}/articles/${article.slug}`,
+ },
+ twitter: {
+ card: article.coverImage ? 'summary_large_image' : 'summary',
+ title: article.title,
+ description: article.excerpt,
+ images: article.coverImage ? [article.coverImage] : undefined,
+ },
+ };
+}
+
+export default async function ArticlePage({ params }: PageProps) {
+ const { slug } = await params;
+ const article = await getArticleBySlug(slug);
+ if (!article) {
+ notFound();
+ }
+
+ const jsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: article.title,
+ description: article.excerpt,
+ datePublished: article.publishedAt,
+ author: { '@type': 'Person', name: article.author.name },
+ publisher: { '@type': 'Organization', name: APP_NAME, url: SITE_URL },
+ url: `${SITE_URL}/articles/${article.slug}`,
+ ...(article.coverImage ? { image: article.coverImage } : {}),
+ };
+
+ const authorHref = profileHref(article.author.username, article.author.id);
+
+ return (
+ <>
+ {article.visibility === 'public' && }
+
+
+
+
+ {ARTICLE_COPY.reader.back}
+
+
+ {article.coverImage && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(public)/articles/new/ArticleComposer.tsx b/src/app/(public)/articles/new/ArticleComposer.tsx
new file mode 100644
index 000000000..eaa5159c9
--- /dev/null
+++ b/src/app/(public)/articles/new/ArticleComposer.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Eye, PenLine } from 'lucide-react';
+import Button from '@/components/ui/Button';
+import Input from '@/components/ui/Input';
+import Textarea from '@/components/ui/Textarea';
+import { cn } from '@/lib/utils';
+import { TIMELINE_SURFACE, TIMELINE_VISIBILITY_OPTIONS } from '@/config/timeline';
+import { ARTICLE_COPY, ARTICLE_LIMITS, estimateReadingTime } from '@/config/articles';
+import { ROUTES } from '@/config/routes';
+import { publishArticle } from '@/services/articles/create';
+import type { TimelineVisibility } from '@/types/timeline';
+import ArticleMarkdown from '../[slug]/ArticleMarkdown';
+
+export default function ArticleComposer({ user }: { user: { id: string } }) {
+ const router = useRouter();
+ const [title, setTitle] = useState('');
+ const [excerpt, setExcerpt] = useState('');
+ const [coverImage, setCoverImage] = useState('');
+ const [body, setBody] = useState('');
+ const [visibility, setVisibility] = useState('public');
+ const [tab, setTab] = useState<'write' | 'preview'>('write');
+ const [publishing, setPublishing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const canPublish = title.trim().length > 0 && body.trim().length > 0 && !publishing;
+ const readingTime = body.trim() ? estimateReadingTime(body) : 0;
+
+ async function handlePublish() {
+ if (!canPublish) {
+ return;
+ }
+ setPublishing(true);
+ setError(null);
+ const result = await publishArticle(user, {
+ title,
+ body,
+ excerpt: excerpt || undefined,
+ coverImage: coverImage || undefined,
+ visibility,
+ });
+ if (!result.success) {
+ setError(result.error);
+ setPublishing(false);
+ return;
+ }
+ router.push(ROUTES.ARTICLE(result.slug));
+ }
+
+ return (
+
+
+
+
+ {ARTICLE_COPY.reader.back}
+
+
+
+
+ {/* Write / Preview tabs */}
+
+
+
+ {readingTime > 0 && (
+
{readingTime} min read
+ )}
+
+
+ {tab === 'write' ? (
+
+ setTitle(e.target.value)}
+ placeholder={ARTICLE_COPY.new.titlePlaceholder}
+ maxLength={ARTICLE_LIMITS.title}
+ aria-label="Article title"
+ className="!text-xl !font-semibold"
+ />
+
+ ) : (
+
+ {coverImage && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+ {title || 'Untitled'}
+
+ {excerpt &&
{excerpt}
}
+
+ {body.trim() ? (
+
+ ) : (
+
Nothing to preview yet.
+ )}
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Publish bar */}
+
+
+ {TIMELINE_VISIBILITY_OPTIONS.map(option => {
+ const Icon = option.Icon;
+ const active = visibility === option.key;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(public)/articles/new/page.tsx b/src/app/(public)/articles/new/page.tsx
new file mode 100644
index 000000000..e4f48769b
--- /dev/null
+++ b/src/app/(public)/articles/new/page.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import Link from 'next/link';
+import { useRequireAuth } from '@/hooks/useAuthRedirects';
+import { ROUTES } from '@/config/routes';
+import Button from '@/components/ui/Button';
+import ArticleComposer from './ArticleComposer';
+
+export default function NewArticlePage() {
+ const { user, isLoading } = useRequireAuth();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!user) {
+ return (
+
+
Sign in to write an article.
+
+
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/app/(public)/articles/page.tsx b/src/app/(public)/articles/page.tsx
new file mode 100644
index 000000000..8fd0189bd
--- /dev/null
+++ b/src/app/(public)/articles/page.tsx
@@ -0,0 +1,99 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { Clock, PenLine } from 'lucide-react';
+import { listPublishedArticles } from '@/services/articles/get-article';
+import { ARTICLE_COPY } from '@/config/articles';
+import { ROUTES } from '@/config/routes';
+
+// Reads the viewer's session cookies (via the server Supabase client), so this
+// route is inherently dynamic — declare it to skip the static-prerender attempt.
+export const dynamic = 'force-dynamic';
+
+export const metadata: Metadata = {
+ title: 'Articles',
+ description: 'Long-form thinking from the OrangeCat community — Bitcoin-native, free to read.',
+};
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+export default async function ArticlesIndexPage() {
+ const articles = await listPublishedArticles();
+
+ return (
+
+
+
+
+ {articles.length === 0 ? (
+
+
{ARTICLE_COPY.index.empty}
+
+
+ {ARTICLE_COPY.index.write}
+
+
+ ) : (
+
+ {articles.map(article => (
+ -
+
+
+
+ {article.title}
+
+ {article.excerpt && (
+
{article.excerpt}
+ )}
+
+ {article.author.name}
+ ·
+
+
+
+ {article.readingTime} min
+
+
+
+ {article.coverImage && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/timeline/PostContent.tsx b/src/components/timeline/PostContent.tsx
index c232b6f19..5d7fd26f7 100644
--- a/src/components/timeline/PostContent.tsx
+++ b/src/components/timeline/PostContent.tsx
@@ -2,11 +2,12 @@
import React from 'react';
import Link from 'next/link';
-import { ExternalLink } from 'lucide-react';
+import { ArrowRight, ExternalLink } from 'lucide-react';
import { TimelineDisplayEvent } from '@/types/timeline';
import { renderMarkdownToReact } from '@/utils/markdown';
import { TIMELINE_SURFACE } from '@/config/timeline';
import { getExternalAttribution } from '@/config/external-publish';
+import { ROUTES } from '@/config/routes';
interface PostContentProps {
event: TimelineDisplayEvent;
@@ -39,6 +40,13 @@ export function PostContent({ event }: PostContentProps) {
const displayContent = getDisplayContent();
+ // Article posts carry their long-form body in metadata; the feed shows a
+ // compact card linking to the full reader instead of the raw excerpt.
+ const articleSlug =
+ event.metadata?.is_article && typeof event.metadata?.article?.slug === 'string'
+ ? (event.metadata.article.slug as string)
+ : null;
+
// Read-only surfacing of an externally-published build event (e.g. FleetCrown):
// a status pill + a "via " deep-link back to the originating surface.
const attribution = getExternalAttribution(event.metadata);
@@ -72,8 +80,32 @@ export function PostContent({ event }: PostContentProps) {
)}
{/* Titles removed for fluid, threads-like design */}
+ {/* Article card: title + excerpt + link to the full reader */}
+ {articleSlug && (
+
+
+ Article
+
+ {event.title && (
+
+ {event.title}
+
+ )}
+ {displayContent && (
+ {displayContent}
+ )}
+
+ Read article
+
+
+
+ )}
+
{/* Event Description/Content */}
- {displayContent && (!isRepost || isQuoteRepost) && (
+ {!articleSlug && displayContent && (!isRepost || isQuoteRepost) && (
{renderMarkdownToReact(displayContent)}
diff --git a/src/config/articles.ts b/src/config/articles.ts
new file mode 100644
index 000000000..d9e9624de
--- /dev/null
+++ b/src/config/articles.ts
@@ -0,0 +1,124 @@
+/**
+ * Articles — SSOT for the long-form text-expression surface.
+ *
+ * An article is NOT a new ENTITY_REGISTRY entity. It is a long-form POST on
+ * `timeline_events`: one row, created through the same `createEventWithVisibility`
+ * pipeline as a short post, carrying its long-form payload in `metadata.article`.
+ * This gives SSOT (no duplicate table), free feed distribution, and ~¼ the code
+ * of a new entity. See the text-expression architecture note.
+ *
+ * Storage shape (metadata.article on the timeline_events row):
+ * { slug, cover_image?, reading_time, body } // body = raw markdown
+ * plus `metadata.is_article = true` as the discriminator, `title` = article
+ * title, and `description` = excerpt (feed + SEO). The feed-distribution RPC
+ * (`create_post_with_visibility`) is used UNCHANGED — hence the payload rides in
+ * `metadata`, which the RPC persists verbatim. No migration required.
+ */
+
+export const ARTICLE_EVENT_TYPE = 'status_update' as const;
+
+/** Discriminator stored at `metadata.is_article`. */
+export const ARTICLE_METADATA_FLAG = 'is_article' as const;
+
+/** Words-per-minute used to estimate reading time. */
+const WORDS_PER_MINUTE = 200;
+
+export const ARTICLE_LIMITS = {
+ /** Article title. Longer than a post title (120) — a headline, not a snippet. */
+ title: 140,
+ /** Excerpt / subtitle shown in the feed and SEO description. */
+ excerpt: 280,
+ /** Markdown body. Generous — this is the whole point of long-form. */
+ body: 100_000,
+ /** Minimum body length before publishing is allowed. */
+ bodyMin: 1,
+} as const;
+
+export const ARTICLE_COPY = {
+ new: {
+ heading: 'Write an article',
+ subheading: 'Long-form, Bitcoin-native, free to publish. Yours to keep.',
+ titlePlaceholder: 'Title',
+ excerptPlaceholder: 'A one-line summary (optional — shown in the feed and previews)',
+ bodyPlaceholder: 'Write your article in Markdown…',
+ coverLabel: 'Cover image URL (optional)',
+ publish: 'Publish',
+ publishing: 'Publishing…',
+ cancel: 'Cancel',
+ },
+ reader: {
+ back: 'All articles',
+ byUnknown: 'Unknown',
+ privateNotice: 'Only you can see this article.',
+ followersNotice: 'Visible to your followers.',
+ },
+ index: {
+ heading: 'Articles',
+ subheading: 'Long-form thinking from the OrangeCat community.',
+ empty: 'No articles published yet.',
+ write: 'Write an article',
+ },
+} as const;
+
+/**
+ * Estimate reading time in whole minutes (>= 1) from raw markdown.
+ * Strips markdown punctuation loosely — good enough for a "5 min read" badge.
+ */
+export function estimateReadingTime(markdown: string): number {
+ const words = markdown
+ .replace(/[#>*_`~\-!\[\]()]/g, ' ')
+ .split(/\s+/)
+ .filter(Boolean).length;
+ return Math.max(1, Math.round(words / WORDS_PER_MINUTE));
+}
+
+/**
+ * Build a URL-safe base slug from a title. Not unique on its own — always pair
+ * with a suffix via {@link buildArticleSlug}.
+ */
+export function slugifyTitle(title: string): string {
+ return (
+ title
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[̀-ͯ]/g, '') // strip combining diacritics
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80) || 'article'
+ );
+}
+
+/**
+ * Deterministic-enough unique slug: `title-base-`. The suffix is a short
+ * random token so two same-titled articles never collide. Generated client-side
+ * at publish time (before the row exists), so it lives in `metadata.article.slug`.
+ */
+export function buildArticleSlug(title: string, suffix: string): string {
+ return `${slugifyTitle(title)}-${suffix}`;
+}
+
+/** A short, URL-safe random token for slug uniqueness. */
+export function shortToken(length = 6): string {
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
+ let out = '';
+ for (let i = 0; i < length; i += 1) {
+ out += alphabet[Math.floor(Math.random() * alphabet.length)];
+ }
+ return out;
+}
+
+/**
+ * Derive an excerpt from the body when the author leaves it blank: first
+ * paragraph, markdown stripped, clamped to the excerpt limit.
+ */
+export function deriveExcerpt(body: string): string {
+ const firstBlock = body.split(/\n\s*\n/).find(b => b.trim().length > 0) ?? '';
+ const plain = firstBlock
+ .replace(/[#>*_`~]/g, '')
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // links/images → text
+ .replace(/\s+/g, ' ')
+ .trim();
+ return plain.length > ARTICLE_LIMITS.excerpt
+ ? `${plain.slice(0, ARTICLE_LIMITS.excerpt - 1).trimEnd()}…`
+ : plain;
+}
diff --git a/src/config/routes.ts b/src/config/routes.ts
index 4183dbdb2..1be730663 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -281,6 +281,9 @@ export const ROUTES = {
HOW_IT_WORKS: '/how-it-works',
BLOG: '/blog',
CHANGELOG: '/changelog',
+ ARTICLES: '/articles',
+ ARTICLES_NEW: '/articles/new',
+ ARTICLE: (slug: string) => `/articles/${slug}`,
DOCS: '/docs',
FAQ: '/faq',
PRICING: '/pricing',
diff --git a/src/services/articles/create.ts b/src/services/articles/create.ts
new file mode 100644
index 000000000..7e298bfbb
--- /dev/null
+++ b/src/services/articles/create.ts
@@ -0,0 +1,86 @@
+/**
+ * Publish an article. Reuses the timeline post-creation pipeline
+ * (`createEventWithVisibility`) verbatim — an article is a long-form post — and
+ * carries its long-form payload in `metadata.article`. No new table, no new RPC.
+ */
+
+import { timelineService } from '@/services/timeline';
+import { logger } from '@/utils/logger';
+import {
+ ARTICLE_EVENT_TYPE,
+ ARTICLE_LIMITS,
+ buildArticleSlug,
+ deriveExcerpt,
+ estimateReadingTime,
+ shortToken,
+} from '@/config/articles';
+import type { CreateArticleInput } from './types';
+
+interface PublishArticleUser {
+ id: string;
+}
+
+type PublishArticleResult = { success: true; slug: string } | { success: false; error: string };
+
+export async function publishArticle(
+ user: PublishArticleUser,
+ input: CreateArticleInput
+): Promise {
+ const title = input.title.trim();
+ const body = input.body.trim();
+
+ if (!title) {
+ return { success: false, error: 'Give your article a title.' };
+ }
+ if (title.length > ARTICLE_LIMITS.title) {
+ return { success: false, error: `Title must be ${ARTICLE_LIMITS.title} characters or fewer.` };
+ }
+ if (body.length < ARTICLE_LIMITS.bodyMin) {
+ return { success: false, error: 'Write something before publishing.' };
+ }
+ if (body.length > ARTICLE_LIMITS.body) {
+ return { success: false, error: 'This article is too long to publish.' };
+ }
+
+ const excerpt = (input.excerpt?.trim() || deriveExcerpt(body)).slice(0, ARTICLE_LIMITS.excerpt);
+ const slug = buildArticleSlug(title, shortToken());
+ const coverImage = input.coverImage?.trim() || undefined;
+
+ // Owner (profile) timeline + community feed when public — mirrors the short-post
+ // distribution contexts so articles surface everywhere posts do.
+ const timelineContexts: Array<{
+ timeline_type: 'profile' | 'project' | 'community';
+ timeline_owner_id: string | null;
+ }> = [{ timeline_type: 'profile', timeline_owner_id: user.id }];
+ if (input.visibility === 'public') {
+ timelineContexts.push({ timeline_type: 'community', timeline_owner_id: null });
+ }
+
+ const result = await timelineService.createEventWithVisibility({
+ eventType: ARTICLE_EVENT_TYPE,
+ actorId: user.id,
+ subjectType: 'profile',
+ subjectId: user.id,
+ title,
+ description: excerpt,
+ visibility: input.visibility,
+ metadata: {
+ is_user_post: true,
+ is_article: true,
+ article: {
+ slug,
+ cover_image: coverImage,
+ reading_time: estimateReadingTime(body),
+ body,
+ },
+ },
+ timelineContexts,
+ });
+
+ if (!result.success) {
+ logger.error('Failed to publish article', { error: result.error }, 'Articles');
+ return { success: false, error: result.error || 'Failed to publish article.' };
+ }
+
+ return { success: true, slug };
+}
diff --git a/src/services/articles/get-article.ts b/src/services/articles/get-article.ts
new file mode 100644
index 000000000..9bbddc7f2
--- /dev/null
+++ b/src/services/articles/get-article.ts
@@ -0,0 +1,125 @@
+/**
+ * Server-side article reads. Articles are `timeline_events` rows flagged with
+ * `metadata.is_article`; we read them through the `enriched_timeline_events`
+ * view (joins actor data for the byline).
+ */
+
+import { createServerClient } from '@/lib/supabase/server';
+import { logger } from '@/utils/logger';
+import { ARTICLE_METADATA_FLAG } from '@/config/articles';
+import type { Article, ArticleMetadataPayload } from './types';
+
+interface ActorData {
+ id: string;
+ username?: string;
+ display_name?: string;
+ avatar_url?: string;
+}
+
+interface EnrichedArticleRow {
+ id: string;
+ title: string | null;
+ description: string | null;
+ visibility: string | null;
+ event_timestamp: string;
+ is_deleted: boolean | null;
+ actor_id: string;
+ actor_data: ActorData | null;
+ metadata: { is_article?: boolean; article?: ArticleMetadataPayload } | null;
+}
+
+const ENRICHED_VIEW = 'enriched_timeline_events';
+
+/** Columns needed to render an article — never `select('*')`. */
+const ARTICLE_COLUMNS =
+ 'id, title, description, visibility, event_timestamp, is_deleted, actor_id, actor_data, metadata';
+
+function rowToArticle(row: EnrichedArticleRow): Article | null {
+ const payload = row.metadata?.article;
+ if (!payload?.slug || typeof payload.body !== 'string') {
+ return null;
+ }
+
+ const actor = row.actor_data;
+ return {
+ id: row.id,
+ slug: payload.slug,
+ title: row.title ?? 'Untitled',
+ excerpt: row.description ?? '',
+ body: payload.body,
+ coverImage: payload.cover_image || undefined,
+ readingTime: payload.reading_time ?? 1,
+ visibility: (row.visibility as Article['visibility']) ?? 'public',
+ publishedAt: row.event_timestamp,
+ author: {
+ id: actor?.id ?? row.actor_id,
+ name: actor?.display_name || actor?.username || 'Unknown',
+ username: actor?.username,
+ avatarUrl: actor?.avatar_url,
+ },
+ };
+}
+
+/**
+ * Fetch a single article by slug. Non-public articles are returned only to their
+ * author (the server client carries the viewer's session cookies), otherwise null.
+ */
+export async function getArticleBySlug(slug: string): Promise {
+ try {
+ const supabase = await createServerClient();
+ const { data, error } = await supabase
+ .from(ENRICHED_VIEW)
+ .select(ARTICLE_COLUMNS)
+ .eq('metadata->article->>slug', slug)
+ .eq('is_deleted', false)
+ .limit(1)
+ .maybeSingle();
+
+ if (error || !data) {
+ return null;
+ }
+
+ const article = rowToArticle(data);
+ if (!article) {
+ return null;
+ }
+
+ if (article.visibility !== 'public') {
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user || user.id !== article.author.id) {
+ return null;
+ }
+ }
+
+ return article;
+ } catch (err) {
+ logger.error('Failed to fetch article by slug', { err, slug }, 'Articles');
+ return null;
+ }
+}
+
+/** List public, published articles, newest first. */
+export async function listPublishedArticles(limit = 30): Promise {
+ try {
+ const supabase = await createServerClient();
+ const { data, error } = await supabase
+ .from(ENRICHED_VIEW)
+ .select(ARTICLE_COLUMNS)
+ .eq(`metadata->>${ARTICLE_METADATA_FLAG}`, 'true')
+ .eq('visibility', 'public')
+ .eq('is_deleted', false)
+ .order('event_timestamp', { ascending: false })
+ .limit(limit)
+ .returns();
+
+ if (error || !data) {
+ return [];
+ }
+ return data.map(rowToArticle).filter((a): a is Article => a !== null);
+ } catch (err) {
+ logger.error('Failed to list published articles', { err }, 'Articles');
+ return [];
+ }
+}
diff --git a/src/services/articles/types.ts b/src/services/articles/types.ts
new file mode 100644
index 000000000..61c603afd
--- /dev/null
+++ b/src/services/articles/types.ts
@@ -0,0 +1,43 @@
+/**
+ * Article domain types. An Article is a projection over a `timeline_events` row
+ * whose `metadata.is_article` is true — see `src/config/articles.ts`.
+ */
+
+export interface ArticleAuthor {
+ id: string;
+ name: string;
+ username?: string;
+ avatarUrl?: string;
+}
+
+export interface Article {
+ /** Underlying timeline_events row id. */
+ id: string;
+ slug: string;
+ title: string;
+ /** Short summary shown in the feed and as the SEO description. */
+ excerpt: string;
+ /** Raw markdown body. */
+ body: string;
+ coverImage?: string;
+ readingTime: number;
+ visibility: 'public' | 'followers' | 'private';
+ publishedAt: string;
+ author: ArticleAuthor;
+}
+
+/** The payload persisted at `metadata.article`. */
+export interface ArticleMetadataPayload {
+ slug: string;
+ cover_image?: string;
+ reading_time: number;
+ body: string;
+}
+
+export interface CreateArticleInput {
+ title: string;
+ body: string;
+ excerpt?: string;
+ coverImage?: string;
+ visibility: 'public' | 'followers' | 'private';
+}