Hey is a decentralized and permissionless social media app built with Lens Protocol 🌿

chore: Improved markdown processing (#5566)

authored by

Bigint and committed by
GitHub
af17864a edc83f4b

+121 -27
+3 -1
apps/web/package.json
··· 37 37 "class-variance-authority": "^0.7.1", 38 38 "clsx": "^2.1.1", 39 39 "family": "^0.1.1", 40 + "hast": "^1.0.0", 41 + "mdast-util-to-markdown": "^2.1.2", 40 42 "motion": "^12.6.3", 41 43 "motion-plus-react": "^0.1.5", 42 44 "plur": "^5.1.0", ··· 58 60 "remark-parse": "^11.0.0", 59 61 "remark-stringify": "^11.0.0", 60 62 "sonner": "^2.0.3", 61 - "strip-markdown": "^6.0.0", 62 63 "tailwind-merge": "^3.2.0", 63 64 "tailwindcss": "^4.1.3", 64 65 "unified": "^11.0.5", ··· 75 76 "@hey/types": "workspace:*", 76 77 "@tailwindcss/aspect-ratio": "^0.4.2", 77 78 "@tailwindcss/forms": "^0.5.10", 79 + "@types/hast": "^3.0.4", 78 80 "@types/node": "^22.14.0", 79 81 "@types/react": "^19.1.0", 80 82 "@types/react-dom": "^19.1.2",
+3 -3
apps/web/src/components/Common/Layout.tsx
··· 65 65 theme={theme as ToasterProps["theme"]} 66 66 toastOptions={{ 67 67 className: "font-sofia-pro", 68 - style: { boxShadow: "none" } 68 + style: { boxShadow: "none", fontSize: "16px" } 69 69 }} 70 70 icons={{ 71 - success: <CheckCircleIcon />, 72 - error: <XCircleIcon />, 71 + success: <CheckCircleIcon className="size-5" />, 72 + error: <XCircleIcon className="size-5" />, 73 73 loading: <Spinner size="xs" /> 74 74 }} 75 75 />
-2
apps/web/src/components/Shared/Markup/index.tsx
··· 6 6 import remarkBreaks from "remark-breaks"; 7 7 // @ts-expect-error 8 8 import linkifyRegex from "remark-linkify-regex"; 9 - import stripMarkdown from "strip-markdown"; 10 9 import Code from "./Code"; 11 10 import MarkupLink from "./MarkupLink"; 12 11 13 12 const plugins = [ 14 - [stripMarkdown, { keep: ["strong", "emphasis", "inlineCode", "delete"] }], 15 13 remarkBreaks, 16 14 linkifyRegex(Regex.url), 17 15 linkifyRegex(Regex.mention),
-2
apps/web/src/components/Shared/Modal/ReportAccount/index.tsx
··· 53 53 return toast.error(Errors.Suspended); 54 54 } 55 55 56 - console.log(reason); 57 - 58 56 return await createReport({ 59 57 variables: { 60 58 request: {
+1 -3
apps/web/src/helpers/prosekit/extension.ts
··· 10 10 } from "prosekit/core"; 11 11 import { defineBold } from "prosekit/extensions/bold"; 12 12 import { defineDoc } from "prosekit/extensions/doc"; 13 - import { defineHeading } from "prosekit/extensions/heading"; 14 13 import { defineItalic } from "prosekit/extensions/italic"; 15 14 import { defineLinkMarkRule, defineLinkSpec } from "prosekit/extensions/link"; 16 15 import { defineMarkRule } from "prosekit/extensions/mark-rule"; ··· 92 91 defineDoc(), 93 92 defineText(), 94 93 defineParagraph(), 95 - defineHeading(), 96 94 defineHistory(), 97 95 defineBaseKeymap(), 98 96 defineBaseCommands(), 99 97 defineItalic(), 100 98 defineBold(), 101 - defineHashtag(), 102 99 defineAutoLink(), 103 100 defineVirtualSelection(), 101 + defineHashtag(), 104 102 defineMention(), 105 103 defineModClickPrevention(), 106 104 definePlaceholder({ placeholder: "What's new?!", strategy: "doc" })
+8 -3
apps/web/src/helpers/prosekit/markdown.ts
··· 1 - import { remarkLinkProtocol } from "@/helpers/prosekit/remarkLinkProtocol"; 2 1 import rehypeParse from "rehype-parse"; 3 2 import rehypeRemark from "rehype-remark"; 4 3 import remarkHtml from "remark-html"; 5 4 import remarkParse from "remark-parse"; 6 5 import remarkStringify from "remark-stringify"; 7 6 import { unified } from "unified"; 7 + import { rehypeJoinParagraph } from "./rehypeJoinParagraph"; 8 + import { customBreakHandler } from "./remarkBreakHandler"; 9 + import { remarkLinkProtocol } from "./remarkLinkProtocol"; 8 10 9 11 // By default, remark-stringify escapes underscores (i.e. "_" => "\_"). We want 10 12 // to disable this behavior so that we can have underscores in mention usernames. ··· 15 17 export const markdownFromHTML = (html: string): string => { 16 18 const markdown = unified() 17 19 .use(rehypeParse) 18 - .use(rehypeRemark) 20 + .use(rehypeJoinParagraph) 21 + .use(rehypeRemark, { newlines: true }) 19 22 .use(remarkLinkProtocol) 20 - .use(remarkStringify) 23 + .use(remarkStringify, { 24 + handlers: { break: customBreakHandler, hardBreak: customBreakHandler } 25 + }) 21 26 .processSync(html) 22 27 .toString(); 23 28
+78
apps/web/src/helpers/prosekit/rehypeJoinParagraph.ts
··· 1 + import type { Element, Node, Parent, Root } from "hast"; 2 + 3 + import type { Plugin } from "unified"; 4 + import { visitParents } from "unist-util-visit-parents"; 5 + 6 + function isParent(node: Node): node is Parent { 7 + return "children" in node && Array.isArray(node.children); 8 + } 9 + 10 + interface Paragraph extends Element { 11 + tagName: "p"; 12 + } 13 + 14 + function isElement(node: Node): node is Element { 15 + return node.type === "element"; 16 + } 17 + 18 + function isParagraph(node: Node): node is Paragraph { 19 + return isElement(node) && node.tagName.toLowerCase() === "p"; 20 + } 21 + 22 + function isParagraphEmpty(node: Paragraph): boolean { 23 + return node.children.length === 0; 24 + } 25 + 26 + function joinParagraphs(a: Paragraph, b: Paragraph): Paragraph { 27 + const br: Element = { 28 + type: "element", 29 + tagName: "br", 30 + properties: {}, 31 + children: [] 32 + }; 33 + 34 + return { 35 + ...a, 36 + ...b, 37 + properties: { ...a.properties, ...b.properties }, 38 + children: [...a.children, br, ...b.children] 39 + }; 40 + } 41 + 42 + function joinChildren<T extends Node>(children: T[]): T[] { 43 + const result: T[] = []; 44 + 45 + for (const child of children) { 46 + const previous = result.at(-1); 47 + if ( 48 + previous && 49 + isParagraph(previous) && 50 + isParagraph(child) && 51 + !isParagraphEmpty(previous) && 52 + !isParagraphEmpty(child) 53 + ) { 54 + result[result.length - 1] = joinParagraphs(previous, child) as Node as T; 55 + } else { 56 + result.push(child); 57 + } 58 + } 59 + 60 + return result; 61 + } 62 + 63 + const rehypeJoinParagraphTransformer = (root: Root): Root => { 64 + visitParents(root, (node) => { 65 + if (isParent(node)) { 66 + node.children = joinChildren(node.children); 67 + } 68 + }); 69 + 70 + return root; 71 + }; 72 + 73 + /** 74 + * A rehype (HTML) plugin that joins adjacent non-empty <p> elements into a 75 + * single <p> element with a <br> element between them. 76 + */ 77 + export const rehypeJoinParagraph: Plugin<[], Root> = () => 78 + rehypeJoinParagraphTransformer;
+11
apps/web/src/helpers/prosekit/remarkBreakHandler.ts
··· 1 + import { defaultHandlers } from "mdast-util-to-markdown"; 2 + 3 + const defaultBreakHandler = defaultHandlers.break; 4 + 5 + export const customBreakHandler: typeof defaultBreakHandler = (...args) => { 6 + const output: string = defaultBreakHandler(...args); 7 + if (output === "\\\n") { 8 + return "\n"; 9 + } 10 + return output; 11 + };
+2 -2
apps/web/src/styles.css
··· 154 154 /* Markup styles */ 155 155 .markup > p, 156 156 ul { 157 - @apply leading-6; 158 - @apply [&:not(:last-child)]:mb-2; 157 + @apply leading-6 list-disc list-inside; 158 + @apply [&:not(:last-child)]:mb-3; 159 159 } 160 160 161 161 .full-page-post-markup > p,
-1
apps/web/vite.config.mjs
··· 37 37 ], 38 38 editor: [ 39 39 "react-markdown", 40 - "strip-markdown", 41 40 "unified", 42 41 "prosekit", 43 42 "rehype-parse",
+15 -10
pnpm-lock.yaml
··· 208 208 family: 209 209 specifier: ^0.1.1 210 210 version: 0.1.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(viem@2.26.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.2))(wagmi@2.14.16(@tanstack/query-core@5.72.2)(@tanstack/react-query@5.72.2(react@19.1.0))(@types/react@19.1.0)(bufferutil@4.0.9)(immer@10.1.1)(react@19.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.26.3(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2)) 211 + hast: 212 + specifier: ^1.0.0 213 + version: 1.0.0 214 + mdast-util-to-markdown: 215 + specifier: ^2.1.2 216 + version: 2.1.2 211 217 motion: 212 218 specifier: ^12.6.3 213 219 version: 12.6.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ··· 271 277 sonner: 272 278 specifier: ^2.0.3 273 279 version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 274 - strip-markdown: 275 - specifier: ^6.0.0 276 - version: 6.0.0 277 280 tailwind-merge: 278 281 specifier: ^3.2.0 279 282 version: 3.2.0 ··· 317 320 '@tailwindcss/forms': 318 321 specifier: ^0.5.10 319 322 version: 0.5.10(tailwindcss@4.1.3) 323 + '@types/hast': 324 + specifier: ^3.0.4 325 + version: 3.0.4 320 326 '@types/node': 321 327 specifier: ^22.14.0 322 328 version: 22.14.0 ··· 4231 4237 hast-util-whitespace@3.0.0: 4232 4238 resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} 4233 4239 4240 + hast@1.0.0: 4241 + resolution: {integrity: sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA==} 4242 + deprecated: Renamed to rehype 4243 + 4234 4244 hastscript@9.0.1: 4235 4245 resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} 4236 4246 ··· 5807 5817 strip-ansi@6.0.1: 5808 5818 resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 5809 5819 engines: {node: '>=8'} 5810 - 5811 - strip-markdown@6.0.0: 5812 - resolution: {integrity: sha512-mSa8FtUoX3ExJYDkjPUTC14xaBAn4Ik5GPQD45G5E2egAmeV3kHgVSTfIoSDggbF6Pk9stahVgqsLCNExv6jHw==} 5813 5820 5814 5821 strnum@1.1.2: 5815 5822 resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} ··· 11812 11819 dependencies: 11813 11820 '@types/hast': 3.0.4 11814 11821 11822 + hast@1.0.0: {} 11823 + 11815 11824 hastscript@9.0.1: 11816 11825 dependencies: 11817 11826 '@types/hast': 3.0.4 ··· 13623 13632 strip-ansi@6.0.1: 13624 13633 dependencies: 13625 13634 ansi-regex: 5.0.1 13626 - 13627 - strip-markdown@6.0.0: 13628 - dependencies: 13629 - '@types/mdast': 4.0.4 13630 13635 13631 13636 strnum@1.1.2: {} 13632 13637