From 3ec364a8b9001894120a2fe47d6827e8cbadee55 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sat, 5 Sep 2026 00:02:01 -0400 Subject: [PATCH] feat(wiki): add wiki service library and markdown pipeline Service layer (createPage/updatePage/restoreRevision/setPageLockdown/ deletePage, slugification, template expansion, wikilink preprocessing, category constants) plus the client-safe markdown rendering pipeline (react-markdown + remark-gfm + remark-directive) shared by the server component and the editor's live preview. Adds the three markdown dependencies to package.json and bun.lock. --- bun.lock | 91 +++++++++++ package.json | 3 + src/lib/wiki/categories.ts | 12 ++ src/lib/wiki/editorFormatting.ts | 172 +++++++++++++++++++++ src/lib/wiki/markdown.tsx | 137 ++++++++++++++++ src/lib/wiki/prepare.ts | 11 ++ src/lib/wiki/service.ts | 257 +++++++++++++++++++++++++++++++ src/lib/wiki/slugify.ts | 15 ++ src/lib/wiki/templates.ts | 35 +++++ src/lib/wiki/wikilinks.ts | 31 ++++ 10 files changed, 764 insertions(+) create mode 100644 src/lib/wiki/categories.ts create mode 100644 src/lib/wiki/editorFormatting.ts create mode 100644 src/lib/wiki/markdown.tsx create mode 100644 src/lib/wiki/prepare.ts create mode 100644 src/lib/wiki/service.ts create mode 100644 src/lib/wiki/slugify.ts create mode 100644 src/lib/wiki/templates.ts create mode 100644 src/lib/wiki/wikilinks.ts diff --git a/bun.lock b/bun.lock index dcd20bd..1aabdb6 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,9 @@ "radix-ui": "^1.6.7", "react": "19.2.3", "react-dom": "19.2.3", + "react-markdown": "10.1.0", + "remark-directive": "4.0.0", + "remark-gfm": "4.0.1", "sharp": "0.34.5", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", @@ -1027,6 +1030,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], @@ -1151,6 +1156,8 @@ "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "7.28.4", "cosmiconfig": "7.1.0", "resolve": "1.22.10" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -1241,6 +1248,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -1603,6 +1612,10 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], @@ -1615,6 +1628,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -1641,6 +1656,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "hasown": "2.0.2", "side-channel": "1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], @@ -1697,6 +1714,8 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "1.0.4", "has-tostringtag": "1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -1849,6 +1868,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1857,12 +1878,34 @@ "md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="], + "mdast-util-directive": ["mdast-util-directive@3.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "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" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "decode-named-character-reference": "1.2.0", "devlop": "1.1.0", "mdast-util-to-string": "4.0.0", "micromark": "4.0.2", "micromark-util-decode-numeric-character-reference": "2.0.2", "micromark-util-decode-string": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "unist-util-stringify-position": "4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "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" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "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" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "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" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "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" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "1.0.5", "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "ccount": "2.0.1", "devlop": "1.1.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-to-markdown": "2.1.2", "parse-entities": "4.0.2", "stringify-entities": "4.0.4", "unist-util-stringify-position": "4.0.0", "vfile-message": "4.0.3" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="], + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "4.0.4", "unist-util-is": "6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "longest-streak": "3.1.0", "mdast-util-phrasing": "4.1.0", "mdast-util-to-string": "4.0.0", "micromark-util-classify-character": "2.0.1", "micromark-util-decode-string": "2.0.1", "unist-util-visit": "5.0.0", "zwitch": "2.0.4" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "4.0.4" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], @@ -1883,6 +1926,22 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "1.2.0", "devlop": "1.1.0", "micromark-factory-destination": "2.0.1", "micromark-factory-label": "2.0.1", "micromark-factory-space": "2.0.1", "micromark-factory-title": "2.0.1", "micromark-factory-whitespace": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-chunked": "2.0.1", "micromark-util-classify-character": "2.0.1", "micromark-util-html-tag-name": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-resolve-all": "2.0.1", "micromark-util-subtokenize": "2.1.0", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-extension-directive": ["micromark-extension-directive@4.0.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "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" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "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" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "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" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "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" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "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" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "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" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.1", "", { "dependencies": { "@types/acorn": "4.0.6", "@types/estree": "1.0.8", "devlop": "1.1.0", "estree-util-is-identifier-name": "3.0.0", "micromark-factory-mdx-expression": "2.0.3", "micromark-factory-space": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-events-to-acorn": "2.0.3", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "vfile-message": "4.0.3" } }, "sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg=="], "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "2.1.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], @@ -2099,6 +2158,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "1.4.0", "object-assign": "4.1.1", "react-is": "16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -2133,6 +2194,8 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-markdown": ["react-markdown@10.1.0", "", { "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" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-mentions": ["react-mentions@4.4.10", "", { "dependencies": { "@babel/runtime": "7.4.5", "invariant": "^2.2.4", "prop-types": "^15.5.8", "substyle": "^9.1.0" }, "peerDependencies": { "react": ">=16.8.3", "react-dom": ">=16.8.3" } }, "sha512-JHiQlgF1oSZR7VYPjq32wy97z1w1oE4x10EuhKjPr4WUKhVzG1uFQhQjKqjQkbVqJrmahf+ldgBTv36NrkpKpA=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -2163,6 +2226,16 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-errors": "1.3.0", "get-proto": "1.0.1", "gopd": "1.2.0", "set-function-name": "2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "remark-directive": ["remark-directive@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^4.0.0", "unified": "^11.0.0" } }, "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "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" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "2.16.1", "path-parse": "1.0.7", "supports-preserve-symlinks-flag": "1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -2255,6 +2328,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "1.1.2", "source-map": "0.6.1" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], @@ -2301,6 +2376,10 @@ "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "optionalDependencies": { "@babel/core": "7.28.4" }, "peerDependencies": { "react": "19.2.3" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], @@ -2357,6 +2436,10 @@ "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], @@ -2403,8 +2486,12 @@ "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], @@ -2441,6 +2528,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-stringify-position": "4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "0.25.11", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.52.4", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "25.0.3", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.30.2", "sass": "1.77.4", "tsx": "4.20.6" }, "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], @@ -2723,6 +2812,8 @@ "mcp-handler/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/package.json b/package.json index 5f0daff..77bf48f 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,9 @@ "radix-ui": "^1.6.7", "react": "19.2.3", "react-dom": "19.2.3", + "react-markdown": "10.1.0", + "remark-directive": "4.0.0", + "remark-gfm": "4.0.1", "sharp": "0.34.5", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", diff --git a/src/lib/wiki/categories.ts b/src/lib/wiki/categories.ts new file mode 100644 index 0000000..b5feece --- /dev/null +++ b/src/lib/wiki/categories.ts @@ -0,0 +1,12 @@ +export const WIKI_CATEGORIES = [ + "Campaign", + "World", + "Lore", + "Characters", + "Plot", + "Media", + "Guides", + "Meta", +] as const; + +export type WikiCategory = (typeof WIKI_CATEGORIES)[number]; diff --git a/src/lib/wiki/editorFormatting.ts b/src/lib/wiki/editorFormatting.ts new file mode 100644 index 0000000..b5a3bdd --- /dev/null +++ b/src/lib/wiki/editorFormatting.ts @@ -0,0 +1,172 @@ +import type { RefObject } from "react"; + +export interface BodySelection { + readonly start: number; + readonly end: number; + readonly text: string; +} + +export interface BodyUpdate { + readonly value: string; + readonly selectionStart: number; + readonly selectionEnd: number; +} + +export interface WrappedFormat { + readonly prefix: string; + readonly suffix: string; + readonly placeholder: string; +} + +export interface SnippetInsertion { + readonly insertion: string; + readonly placeholder?: string; +} + +export interface ReferenceInsertion { + readonly definitionSuffix: string; +} + +export const TWO_COLUMN_SNIPPET = `::::columns +:::column +Left column content +::: + +:::column +Right column content +::: +::::`; + +export const THREE_COLUMN_SNIPPET = `::::columns +:::column +Left column content +::: + +:::column +Center column content +::: + +:::column +Right column content +::: +::::`; + +export const TABLE_SNIPPET = `| Header | Header | +| --- | --- | +| Cell | Cell |`; + +export const IMAGE_SNIPPET = `![Alt text](https:// "Caption on hover")`; + +export function readBodySelection( + body: string, + bodyRef: RefObject, +): BodySelection { + const start = bodyRef.current?.selectionStart ?? body.length; + const end = bodyRef.current?.selectionEnd ?? body.length; + return { start, end, text: body.slice(start, end) }; +} + +export function createWrappedUpdate( + body: string, + selection: BodySelection, + format: WrappedFormat, +): BodyUpdate { + const content = selection.text || format.placeholder; + const value = `${body.slice(0, selection.start)}${format.prefix}${content}${format.suffix}${body.slice(selection.end)}`; + const contentStart = selection.start + format.prefix.length; + + return { + value, + selectionStart: contentStart, + selectionEnd: contentStart + content.length, + }; +} + +export function createLinePrefixUpdate( + body: string, + selection: BodySelection, + prefix: string, +): BodyUpdate { + const hasSelection = selection.start !== selection.end; + const lineStart = body.lastIndexOf("\n", selection.start - 1) + 1; + + if (!hasSelection) { + return { + value: `${body.slice(0, lineStart)}${prefix}${body.slice(lineStart)}`, + selectionStart: selection.start + prefix.length, + selectionEnd: selection.start + prefix.length, + }; + } + + const selectedEnd = + selection.end > selection.start && body[selection.end - 1] === "\n" + ? selection.end - 1 + : selection.end; + const nextLine = body.indexOf("\n", selectedEnd); + const lineEnd = nextLine === -1 ? body.length : nextLine; + const formatted = body + .slice(lineStart, lineEnd) + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n"); + + return { + value: `${body.slice(0, lineStart)}${formatted}${body.slice(lineEnd)}`, + selectionStart: lineStart, + selectionEnd: lineStart + formatted.length, + }; +} + +export function createSnippetUpdate( + body: string, + selection: BodySelection, + snippet: SnippetInsertion, +): BodyUpdate { + const placeholder = snippet.placeholder; + const placeholderStart = placeholder ? snippet.insertion.indexOf(placeholder) : -1; + const selectionStart = + placeholderStart === -1 + ? selection.start + snippet.insertion.length + : selection.start + placeholderStart; + const selectionEnd = + placeholderStart === -1 ? selectionStart : selectionStart + (placeholder?.length ?? 0); + + return { + value: `${body.slice(0, selection.start)}${snippet.insertion}${body.slice(selection.end)}`, + selectionStart, + selectionEnd, + }; +} + +export function createReferenceUpdate( + body: string, + selection: BodySelection, + reference: ReferenceInsertion, +): BodyUpdate { + const number = (body.match(/\[\^/g) ?? []).length + 1; + const marker = `[^${number}]`; + const bodyWithMarker = `${body.slice(0, selection.start)}${marker}${body.slice(selection.end)}`; + const definition = `${marker}: ${reference.definitionSuffix}`; + const separator = + bodyWithMarker.length === 0 + ? "" + : bodyWithMarker.endsWith("\n\n") + ? "" + : bodyWithMarker.endsWith("\n") + ? "\n" + : "\n\n"; + const value = `${bodyWithMarker}${separator}${definition}`; + + if (reference.definitionSuffix) { + const definitionStart = + bodyWithMarker.length + separator.length + definition.indexOf(reference.definitionSuffix); + return { + value, + selectionStart: definitionStart, + selectionEnd: definitionStart + reference.definitionSuffix.length, + }; + } + + const cursor = bodyWithMarker.length + separator.length + definition.length; + return { value, selectionStart: cursor, selectionEnd: cursor }; +} diff --git a/src/lib/wiki/markdown.tsx b/src/lib/wiki/markdown.tsx new file mode 100644 index 0000000..4c7ae2c --- /dev/null +++ b/src/lib/wiki/markdown.tsx @@ -0,0 +1,137 @@ +import type { Components } from "react-markdown"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkDirective from "remark-directive"; +import type { State } from "mdast-util-to-hast"; +import type { ContainerDirective, LeafDirective, TextDirective } from "mdast-util-directive"; +import { cn } from "@/lib/utils"; +import { Info, TriangleAlert } from "lucide-react"; + +/** + * Shared markdown pipeline for wiki pages. + * + * Client-safe: no Payload imports, no "use client" — usable from both server + * components (WikiContent) and client components (WikiEditor live preview). + */ + +/** remark plugins: GFM (tables, strikethrough, task lists, footnotes) + directives. */ +export const wikiRemarkPlugins = [remarkGfm, remarkDirective]; + +type DirectiveNode = ContainerDirective | LeafDirective | TextDirective; + +/** + * Build a remark-rehype handler for directive nodes. + * + * The div variant is the verified shape from the remark-directive docs: + * name -> className, `class` attribute tokenized into className, other + * attributes -> properties, then state.patch + state.applyData. The text + * variant renders a span (a div inside a

is invalid HTML). + */ +function makeDirectiveHandler(tagName: "div" | "span") { + return function directiveHandler(state: State, node: DirectiveNode) { + const result = { + type: "element", + tagName, + properties: {}, + children: state.all(node), + } as any; + if (node.name) result.properties.className = [node.name]; + if (node.attributes) { + for (const [k, v] of Object.entries(node.attributes)) { + if (v === null || v === undefined) continue; + if (k === "class") { + result.properties.className = [ + ...(result.properties.className ?? []), + ...String(v).split(/\s+/), + ]; + } else { + result.properties[k] = v; + } + } + } + state.patch(node, result); + return state.applyData(node, result); + }; +} + +/** remark-rehype options: directives become divs (container/leaf) or spans (text). */ +export const wikiRemarkRehypeOptions = { + handlers: { + containerDirective: makeDirectiveHandler("div"), + leafDirective: makeDirectiveHandler("div"), + textDirective: makeDirectiveHandler("span"), + }, +}; + +/** Component overrides shared by the wiki detail page and the editor preview. */ +export const wikiMarkdownComponents: Components = { + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) => , + ol: ({ children }) =>
    {children}
, + a: ({ children, href }) => ( + + {children} + + ), + code: ({ children, className }) => ( + + {children} + + ), + pre: ({ children }) => ( +
+      {children}
+    
+ ), + blockquote: ({ children }) => ( +
{children}
+ ), + table: ({ children }) => ( +
+ {children}
+
+ ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => {children}, + img: ({ src, alt, title }) => { + if (!src) return null; + + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ); + }, + div: ({ className, node, children }) => { + const rawClass = node?.properties?.className; + const directiveName = Array.isArray(rawClass) ? String(rawClass[0]) : undefined; + const isCallout = directiveName === "note" || directiveName === "warning"; + const Icon = directiveName === "warning" ? TriangleAlert : Info; + + return ( +
+ {isCallout &&
+ ); + }, +}; + +// Re-export the component so consumers import the whole pipeline from one +// module. react-markdown@10.1.0's default export is the component (a named +// `Markdown` export only exists in later versions). +export { ReactMarkdown }; diff --git a/src/lib/wiki/prepare.ts b/src/lib/wiki/prepare.ts new file mode 100644 index 0000000..50c2776 --- /dev/null +++ b/src/lib/wiki/prepare.ts @@ -0,0 +1,11 @@ +/** + * Single entry point the frontend uses before rendering wiki markdown: + * template expansion first, then wikilink preprocessing. + */ + +import { expandTemplates, type TemplateMap } from "./templates"; +import { preprocessWikilinks } from "./wikilinks"; + +export function prepareMarkdown(source: string, templates: TemplateMap): string { + return preprocessWikilinks(expandTemplates(source, templates)); +} \ No newline at end of file diff --git a/src/lib/wiki/service.ts b/src/lib/wiki/service.ts new file mode 100644 index 0000000..9995728 --- /dev/null +++ b/src/lib/wiki/service.ts @@ -0,0 +1,257 @@ +/** + * Wiki page service — server-only domain logic. + * + * All mutations run with `overrideAccess: true` (the wiki-revisions collection + * denies create/update/delete outright; wiki-pages allows them for logged-in + * users but the service must not depend on request auth). Throws descriptive + * `Error`s on violations. Does NOT emit events — the actions layer emits. + */ + +import type { Payload } from "payload"; +import type { User, WikiPage, WikiRevision, WikiTemplate } from "@/payload-types"; +import { slugify } from "./slugify"; +import type { TemplateMap } from "./templates"; + +type WikiCategory = WikiPage["category"]; + +async function uniqueSlug(payload: Payload, base: string): Promise { + let candidate = base; + let suffix = 2; + for (;;) { + const existing = (await payload.find({ + collection: "wiki-pages", + where: { slug: { equals: candidate } }, + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: WikiPage[] }; + if (existing.docs.length === 0) return candidate; + candidate = `${base}-${suffix}`; + suffix += 1; + } +} + +async function nextRevisionNumber(payload: Payload, pageId: number): Promise { + const revisions = (await payload.find({ + collection: "wiki-revisions", + where: { page: { equals: pageId } }, + sort: "-revisionNumber", + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: WikiRevision[] }; + const max = revisions.docs.length > 0 ? revisions.docs[0].revisionNumber : 0; + return max + 1; +} + +export async function createPage( + payload: Payload, + user: User, + input: { title: string; category: string; tags?: string[]; body: string; summary?: string }, +): Promise { + const slug = await uniqueSlug(payload, slugify(input.title)); + + const page = (await payload.create({ + collection: "wiki-pages", + data: { + title: input.title, + slug, + category: input.category as WikiCategory, + tags: input.tags ?? [], + body: input.body, + lastEditor: user.id, + }, + overrideAccess: true, + depth: 0, + })) as unknown as WikiPage; + + await payload.create({ + collection: "wiki-revisions", + data: { + page: page.id, + revisionNumber: 1, + titleSnapshot: input.title, + contentSnapshot: input.body, + editor: user.id, + type: "create", + summary: input.summary ?? "Created page", + }, + overrideAccess: true, + depth: 0, + }); + + return page; +} + +export async function updatePage( + payload: Payload, + user: User, + page: WikiPage, + input: { title?: string; body?: string; category?: string; tags?: string[]; summary?: string }, +): Promise { + if (page.lockdown) { + throw new Error("This page is locked and cannot be edited."); + } + + const nextTitle = input.title ?? page.title; + const nextBody = input.body ?? page.body; + const nextCategory = input.category ?? page.category; + const nextTags = input.tags ?? page.tags ?? []; + + const revisionNumber = await nextRevisionNumber(payload, page.id); + + await payload.create({ + collection: "wiki-revisions", + data: { + page: page.id, + revisionNumber, + titleSnapshot: page.title, + contentSnapshot: page.body, + editor: user.id, + type: "edit", + summary: input.summary ?? "Edited page", + }, + overrideAccess: true, + depth: 0, + }); + + const slug = + nextTitle !== page.title ? await uniqueSlug(payload, slugify(nextTitle)) : page.slug; + + return (await payload.update({ + collection: "wiki-pages", + id: page.id, + data: { + title: nextTitle, + slug, + body: nextBody, + category: nextCategory as WikiCategory, + tags: nextTags, + lastEditor: user.id, + }, + overrideAccess: true, + depth: 0, + })) as unknown as WikiPage; +} + +export async function restoreRevision( + payload: Payload, + user: User, + page: WikiPage, + revisionNumber: number, +): Promise { + if (page.lockdown) { + throw new Error("This page is locked and cannot be edited."); + } + + const found = (await payload.find({ + collection: "wiki-revisions", + where: { + and: [{ page: { equals: page.id } }, { revisionNumber: { equals: revisionNumber } }], + }, + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: WikiRevision[] }; + + const target = found.docs[0]; + if (!target) { + throw new Error(`Revision ${revisionNumber} not found for this page.`); + } + + const next = await nextRevisionNumber(payload, page.id); + + await payload.create({ + collection: "wiki-revisions", + data: { + page: page.id, + revisionNumber: next, + titleSnapshot: target.titleSnapshot, + contentSnapshot: target.contentSnapshot, + editor: user.id, + type: "restore", + summary: `Restored from revision ${revisionNumber}`, + restoredFromRevision: revisionNumber, + }, + overrideAccess: true, + depth: 0, + }); + + const restoredTitle = target.titleSnapshot; + const slug = + restoredTitle !== page.title ? await uniqueSlug(payload, slugify(restoredTitle)) : page.slug; + + return (await payload.update({ + collection: "wiki-pages", + id: page.id, + data: { + title: restoredTitle, + slug, + body: target.contentSnapshot, + lastEditor: user.id, + }, + overrideAccess: true, + depth: 0, + })) as unknown as WikiPage; +} + +export async function setPageLockdown( + payload: Payload, + user: User, + page: WikiPage, + lockdown: boolean, +): Promise { + return (await payload.update({ + collection: "wiki-pages", + id: page.id, + data: { + lockdown, + lockedBy: lockdown ? user.id : null, + lockedAt: lockdown ? new Date().toISOString() : null, + }, + overrideAccess: true, + depth: 0, + })) as unknown as WikiPage; +} + +export async function deletePage(payload: Payload, user: User, page: WikiPage): Promise { + const revisions = (await payload.find({ + collection: "wiki-revisions", + where: { page: { equals: page.id } }, + limit: 500, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: WikiRevision[] }; + + for (const revision of revisions.docs) { + await payload.delete({ + collection: "wiki-revisions", + id: revision.id, + overrideAccess: true, + }); + } + + await payload.delete({ + collection: "wiki-pages", + id: page.id, + overrideAccess: true, + }); +} + +export async function listTemplates(payload: Payload): Promise { + const result = (await payload.find({ + collection: "wiki-templates", + limit: 500, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: WikiTemplate[] }; + return result.docs; +} + +export function templateMap(templates: WikiTemplate[]): TemplateMap { + const map: TemplateMap = {}; + for (const template of templates) { + map[template.name] = template.body; + } + return map; +} \ No newline at end of file diff --git a/src/lib/wiki/slugify.ts b/src/lib/wiki/slugify.ts new file mode 100644 index 0000000..51ae1dd --- /dev/null +++ b/src/lib/wiki/slugify.ts @@ -0,0 +1,15 @@ +/** + * Deterministic URL slug from a page title. + * + * Lowercases, trims, collapses runs of non-alphanumeric characters into a + * single hyphen, strips leading/trailing hyphens, and falls back to "page" + * when the result would be empty. + */ +export function slugify(title: string): string { + const slug = title + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "page"; +} \ No newline at end of file diff --git a/src/lib/wiki/templates.ts b/src/lib/wiki/templates.ts new file mode 100644 index 0000000..2c0adaa --- /dev/null +++ b/src/lib/wiki/templates.ts @@ -0,0 +1,35 @@ +/** + * Wiki template expansion. + * + * Pure module — safe to import client-side. Replaces `{{Name}}` placeholders + * (name trimmed, exact match on map keys) with the template body. Nested + * placeholders expand up to at most 2 levels deep: the page body is level 0, + * template bodies expanded from the page are level 1, templates inside those + * are level 2, and any `{{...}}` found at level 2+ is left as literal text. + * If a template name repeats anywhere in the current expansion chain, that + * `{{Name}}` is left literal (cycle guard). + */ + +export type TemplateMap = Record; + +const PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g; + +function expandLevel( + text: string, + templates: TemplateMap, + depth: number, + chain: string[], +): string { + if (depth >= 2) return text; + return text.replace(PLACEHOLDER_RE, (match, rawName: string) => { + const name = rawName.trim(); + const body = templates[name]; + if (body === undefined) return match; + if (chain.includes(name)) return match; + return expandLevel(body, templates, depth + 1, [...chain, name]); + }); +} + +export function expandTemplates(source: string, templates: TemplateMap): string { + return expandLevel(source, templates, 0, []); +} \ No newline at end of file diff --git a/src/lib/wiki/wikilinks.ts b/src/lib/wiki/wikilinks.ts new file mode 100644 index 0000000..28d9ee5 --- /dev/null +++ b/src/lib/wiki/wikilinks.ts @@ -0,0 +1,31 @@ +/** + * Wiki wikilink preprocessing. + * + * Pure module — safe to import client-side. Converts `[[Page Title]]` into + * `[Page Title](/wiki/)` and `[[Page Title|Custom Text]]` into + * `[Custom Text](/wiki/)`, where slug = slugify(trimmed Page Title). + */ + +import { slugify } from "./slugify"; + +const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +export function preprocessWikilinks(source: string): string { + return source.replace(WIKILINK_RE, (_match, title: string, custom?: string) => { + const target = title.trim(); + const slug = slugify(target); + const label = custom ? custom.trim() : target; + return `[${label}](/wiki/${slug})`; + }); +} + +/** Returns the raw target titles (before the pipe) in document order. */ +export function extractWikilinks(source: string): string[] { + const links: string[] = []; + const re = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + let match: RegExpExecArray | null; + while ((match = re.exec(source)) !== null) { + links.push(match[1].trim()); + } + return links; +} \ No newline at end of file