From 98c8ccef229d6922a40a808f10f2602dfc62cea0 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:13:06 +0000 Subject: [PATCH 1/4] Add WYSIWYG editor for blog posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TipTap-based rich text editor at /edit/[slug] - Matches site typography (Canela fonts, 72ch width, 200% line-height) - MDX components shown as read-only placeholder blocks - Save with Cmd+S, writes back to .mdx files - Conflict detection when file is edited externally (VS Code) - Dev-mode only (disabled in production) New files: - src/pages/edit/[...slug].astro - editor page - src/components/editor/PostEditor.tsx - TipTap editor - src/components/editor/editor-styles.css - prose styling - src/pages/api/save-post.ts - save API with conflict handling Config changes: - astro.config.mjs: output: "server" for dynamic routes - Added prerender: true to static pages to maintain SSG ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- astro.config.mjs | 3 + package-lock.json | 768 ++++++++++++++++++++++++ package.json | 4 + src/components/editor/PostEditor.tsx | 529 ++++++++++++++++ src/components/editor/editor-styles.css | 380 ++++++++++++ src/pages/[...slug].astro | 3 + src/pages/api/save-post.ts | 84 +++ src/pages/edit/[...slug].astro | 140 +++++ src/pages/now-[slug].astro | 3 + src/pages/og/[...slug].png.ts | 3 + src/pages/topics/[topic].astro | 3 + 11 files changed, 1920 insertions(+) create mode 100644 src/components/editor/PostEditor.tsx create mode 100644 src/components/editor/editor-styles.css create mode 100644 src/pages/api/save-post.ts create mode 100644 src/pages/edit/[...slug].astro diff --git a/astro.config.mjs b/astro.config.mjs index a2c22936..0c684e60 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -9,6 +9,9 @@ import { remarkWikiLink } from "./src/plugins/remark-wiki-link"; // https://astro.build/config export default defineConfig({ site: "https://maggieappleton.com", + // Server mode with static as default - allows /edit/* pages to be dynamic + // Most pages prerender by default; specific pages opt-out with prerender = false + output: "server", image: { domains: ["res.cloudinary.com"], }, diff --git a/package-lock.json b/package-lock.json index 1784a984..56dcd808 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ "@astrojs/rss": "^4.0.10", "@iconify-json/heroicons": "^1.2.1", "@iconify-json/lucide": "^1.2.62", + "@tiptap/extension-placeholder": "^3.14.0", + "@tiptap/pm": "^3.14.0", + "@tiptap/react": "^3.14.0", + "@tiptap/starter-kit": "^3.14.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "astro": "^5.0.2", @@ -1208,6 +1212,34 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT", + "optional": true + }, "node_modules/@iconify-json/heroicons": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@iconify-json/heroicons/-/heroicons-1.2.1.tgz", @@ -1771,6 +1803,12 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rollup/pluginutils": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", @@ -2113,6 +2151,453 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@tiptap/core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.14.0.tgz", + "integrity": "sha512-nm0VWVA1Vq/jaKY3wyRXViL/kf78yMdH7qETpv4qZXDQLU+pdWV3IGoRTQTKESc7d8L1wL/2uCeByLNUJfrSIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.14.0.tgz", + "integrity": "sha512-I7aOqcVLHBgCeRtMaMHA+ILSS8Sli46fjFq8477stOpQ79TPiBd6e4SDuFCAu58M94mVLMvlPKF2Eh5IvbIMyQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.14.0.tgz", + "integrity": "sha512-T4ma6VLoHm9JupglidD3CfZXm89A3HMv99gLplXNizvy1mlr4R3uC3aBqKw6lAP+NoqCqbIgjwc4YYsqZClNwA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.14.0.tgz", + "integrity": "sha512-nraHy+5jumT67J7hWrCuVwVTS2vNj4FpV5kO8epVySBmgEBr/7Pyi4w7mQA1VRVOMdjeN9iypbgQ2rKhpfaoTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.14.0.tgz", + "integrity": "sha512-luqPX4u52hiOAHJ95mYsNE+x+9dZxsM461Xny9d/eTXLjAcnwS7MghjrnpljvyYsSXNiwQtxUyEr4uEZZJ5gIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.14.0.tgz", + "integrity": "sha512-Sx9yLorzS+oqNmXID4jt0G5tDnsEgU0HtEXPLD3KNt/ltVxWJU0AXwCsp1/Dg0HIDL868vWpJ2jC1t/4oaf9kA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.14.0.tgz", + "integrity": "sha512-hRSdIhhm3Q9JBMQdKaifRVFnAa4sG+M7l1QcTKR3VSYVy2/oR0U+aiOifi5OvMRBUwhaR71Ro+cMT9FH9s26Kg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.14.0.tgz", + "integrity": "sha512-O3D7/GPB3XrWGy0y/b4LMHiY0eTd+dyIbSdiFtmUnbC/E9lqQLw43GiqvD9Gm6AyKhBA+Z45dKMbaOe1c6eTwQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.14.0.tgz", + "integrity": "sha512-IwHyiZKLjV9WSBlQFS+afMjucIML8wFAKkG8UKCu+CVOe/Qd1ImDGyv6rzPlCmefJkDHIUWS+c2STapJlUD1VQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.14.0.tgz", + "integrity": "sha512-+ErwDF74NzX4JV0nXMSIUT9V8FDdo85r0SaBZ8lb2NLmElaA3LDklcNV7SsoKlRcwsAXtFkqQbDwXLNGQLYSPQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.14.0.tgz", + "integrity": "sha512-hMg2U59+c9FreYtTvzxx5GWKejdZLRITMLEu4OTfrgQok6uF4qkzGEEqmYqPiHk08TBqAg18Y5bbpyqTsuit9A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.14.0.tgz", + "integrity": "sha512-XKxr8usQp+kFevhDK6Ccmnq1CIkLmPClhKwbt7AClGLKLBtEVAS1qUgcmKudkw8cD8Q2/69twI37LXa23sfuLA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.14.0.tgz", + "integrity": "sha512-4xpahSo3b1dN2nwA0XKXLQVz9nZ/vE443a/Y5QLWeXiu3v9wkcMs/5kQ5ysFeDZRBTfVUWBqhngI7zhvDUx2zQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.14.0.tgz", + "integrity": "sha512-65O4T9vPKLUKO1fLowh5jqtfQlH5eaIL7qb/uj5sXMMg8O7TCvBIRkwNuYsFTkJmTk4vBy+fjZ0uwSY3DFkO1g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.14.0.tgz", + "integrity": "sha512-Arl5EaG4wdyipwvKjsI7Krlk3OkmqvLfF0YfGwsd5AVDxTiYuiDGgz7RF8J2kttbBeiUTqwME5xpkryQK3F+fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.14.0.tgz", + "integrity": "sha512-xaeJIktD42rJ4t9fbQpKe+yYNZ+YFIK96cp1Kdm0hZHv/8MPMNRiF85TRY+9U1aoyh5uRcspgCj7EKQb2Hs7qg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.14.0.tgz", + "integrity": "sha512-rsjFH0Vd/4UbDsjwMLay7oz72VVu1r35t8ofAzy5587jn5JAjflaZs05XbRRMD2imUTK41dyajVSh8CqSnDEJw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.14.0.tgz", + "integrity": "sha512-19Dcp8HCFdhINmRy0KQLFfz9ZEuVwFWGAAjYG7BvMvkd9k4sJ5vCv5fej59G99rhsc+tCmik77w+SLksOcxwKQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.14.0.tgz", + "integrity": "sha512-1oPbvNnQjeOxkHZcUbWPx/IY9o4fT3QGk/9A9cIjFrJRD2AHzbYfPDHNHINtg7Bj0jWz74cHvAHcaxP+M27jkA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.14.0.tgz", + "integrity": "sha512-/fXjVL4JajkJQoc213iiput0bCXC4ztUPUpvNuI62VcgFKHcTvX4eYxED1VflotCx0OdkyY9yYD8PtvyO5lkmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.14.0.tgz", + "integrity": "sha512-NFxk2yNo3Cvh9g8evea+yTLNV48se7MbMcVizTnVhobqtBKv793qsb5FM5Hu30Y72FQPNfH+LRoap4XZyBPfVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.14.0.tgz", + "integrity": "sha512-sBiAs1gumdSZXO0ezMSmOkHnlzZNZ1fttm6GriAMIp5xfCvo/0LD6bHPXtvOAbT9ovLQX8mH5+iPZh2jKta7oQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.14.0.tgz", + "integrity": "sha512-R8BbAhnWpisBml6okMKl98hY4tJjedTTgyTkx8tPabIJ92nS9IURKEk3foWB9uHxdTOBUqTvVT+2ScDf9r6QHg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.14.0.tgz", + "integrity": "sha512-XlpnD87LQ7lLcDcBenHgzxv3uivQzPdVHM16CY4lXR4aKDIp2mxjPZr4twHT+cOnRQHc8VYpRgkEo6LLX6VylA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.14.0.tgz", + "integrity": "sha512-zmnWlsi2g/tMlThHby0Je9O+v24j4d+qcXF3nuzLUUaDsGCEtOyC9RzwITft59ViK+Nc2PD2W/J14rsB0j+qoQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.14.0.tgz", + "integrity": "sha512-qQBVKqzU4ZVjRn8W0UbdfE4LaaIgcIWHOMrNnJ+PutrRzQ6ZzhmD/kRONvRWBfG9z3DU7pSKGwVYSR2hztsGuQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.14.0.tgz", + "integrity": "sha512-xrZmqI5jl4yMeAsu8p8gVP9S3An5h2MBi8BQHNnZmpyzkUrlpd40vlT6u13SWIqVi5ZWhBZ6U3rL7mkVLZuRKg==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.14.0.tgz", + "integrity": "sha512-Eo/nLyKxHvnLIF4gI2WFhGJiVrqfA6XL9kismVG9NwBNF/NblMDmZZu6Z2SH/ONJQz2Egn7UBPNp3BMq/qZDcg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.14.0", + "@tiptap/extension-floating-menu": "^3.14.0" + }, + "peerDependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/pm": "^3.14.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.14.0.tgz", + "integrity": "sha512-fHsC4oDVzvMU9btg+IUmu/eqPquapjJ341qaNI7cCeSCKjjE6XJEN6WcONLAVId2OZUwML0IX1Jgl+6gJxU9Jw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.14.0", + "@tiptap/extension-blockquote": "^3.14.0", + "@tiptap/extension-bold": "^3.14.0", + "@tiptap/extension-bullet-list": "^3.14.0", + "@tiptap/extension-code": "^3.14.0", + "@tiptap/extension-code-block": "^3.14.0", + "@tiptap/extension-document": "^3.14.0", + "@tiptap/extension-dropcursor": "^3.14.0", + "@tiptap/extension-gapcursor": "^3.14.0", + "@tiptap/extension-hard-break": "^3.14.0", + "@tiptap/extension-heading": "^3.14.0", + "@tiptap/extension-horizontal-rule": "^3.14.0", + "@tiptap/extension-italic": "^3.14.0", + "@tiptap/extension-link": "^3.14.0", + "@tiptap/extension-list": "^3.14.0", + "@tiptap/extension-list-item": "^3.14.0", + "@tiptap/extension-list-keymap": "^3.14.0", + "@tiptap/extension-ordered-list": "^3.14.0", + "@tiptap/extension-paragraph": "^3.14.0", + "@tiptap/extension-strike": "^3.14.0", + "@tiptap/extension-text": "^3.14.0", + "@tiptap/extension-underline": "^3.14.0", + "@tiptap/extensions": "^3.14.0", + "@tiptap/pm": "^3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -2293,6 +2778,12 @@ "@types/sizzle": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", @@ -2300,6 +2791,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/masonry-layout": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@types/masonry-layout/-/masonry-layout-4.2.8.tgz", @@ -2319,6 +2820,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -2397,6 +2904,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -3583,6 +4096,12 @@ "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/crossws": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.1.tgz", @@ -4453,6 +4972,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -5729,6 +6257,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/lite-youtube-embed": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/lite-youtube-embed/-/lite-youtube-embed-0.3.3.tgz", @@ -7435,6 +7969,12 @@ "node": ">=8" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -7832,6 +8372,213 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", + "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", + "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", + "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", + "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-trailing-node/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", + "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.4", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", + "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8369,6 +9116,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-async": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", @@ -9879,6 +10632,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10673,6 +11435,12 @@ "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", "license": "MIT" }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/package.json b/package.json index af227889..d28d12e3 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "@astrojs/rss": "^4.0.10", "@iconify-json/heroicons": "^1.2.1", "@iconify-json/lucide": "^1.2.62", + "@tiptap/extension-placeholder": "^3.14.0", + "@tiptap/pm": "^3.14.0", + "@tiptap/react": "^3.14.0", + "@tiptap/starter-kit": "^3.14.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "astro": "^5.0.2", diff --git a/src/components/editor/PostEditor.tsx b/src/components/editor/PostEditor.tsx new file mode 100644 index 00000000..dd5c486e --- /dev/null +++ b/src/components/editor/PostEditor.tsx @@ -0,0 +1,529 @@ +import { useEditor, EditorContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Placeholder from "@tiptap/extension-placeholder"; +import { useState, useEffect, useCallback, useRef } from "react"; +import "./editor-styles.css"; + +interface PostEditorProps { + initialContent: string; + slug: string; + collection: string; + filePath: string; + lastModified: number; +} + +interface ParsedContent { + frontmatter: string; + imports: string; + segments: ContentSegment[]; +} + +interface ContentSegment { + type: "prose" | "component"; + content: string; + componentName?: string; +} + +// Parse MDX content into frontmatter, imports, and content segments +function parseMdxContent(content: string): ParsedContent { + const lines = content.split("\n"); + let frontmatter = ""; + let imports = ""; + const segments: ContentSegment[] = []; + + let inFrontmatter = false; + let frontmatterEnded = false; + let importsEnded = false; + let currentProse = ""; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Handle frontmatter + if (i === 0 && line.trim() === "---") { + inFrontmatter = true; + frontmatter += line + "\n"; + continue; + } + + if (inFrontmatter) { + frontmatter += line + "\n"; + if (line.trim() === "---") { + inFrontmatter = false; + frontmatterEnded = true; + } + continue; + } + + // Handle imports (lines starting with "import") + if (frontmatterEnded && !importsEnded) { + if (line.trim().startsWith("import ") || line.trim() === "") { + imports += line + "\n"; + continue; + } else { + importsEnded = true; + } + } + + // Now we're in the content area - detect block-level MDX components + // A block component starts at the beginning of a line with (self-closing) + const selfClosingMatch = line.match(new RegExp(`^<${componentName}[^>]*/>`)); + if (selfClosingMatch) { + segments.push({ + type: "component", + content: componentContent, + componentName, + }); + continue; + } + + // Check if it opens AND closes on the same line + // Match pattern: ... + const sameLineCloseMatch = line.match(new RegExp(``)); + if (sameLineCloseMatch) { + segments.push({ + type: "component", + content: componentContent, + componentName, + }); + continue; + } + + // Multi-line component - find closing tag + // We need to track depth for nested same-name components + let depth = 1; + let j = i + 1; + + while (j < lines.length && depth > 0) { + componentContent += "\n" + lines[j]; + + // Count opening tags on this line (excluding self-closing) + // Look for , or end of pattern + const openMatches = lines[j].match(new RegExp(`<${componentName}(?:\\s|>)`, "g")) || []; + const selfCloseMatches = lines[j].match(new RegExp(`<${componentName}[^>]*/>`,"g")) || []; + const closeMatches = lines[j].match(new RegExp(``, "g")) || []; + + depth += openMatches.length - selfCloseMatches.length - closeMatches.length; + j++; + } + + segments.push({ + type: "component", + content: componentContent, + componentName, + }); + i = j - 1; // Skip the lines we've consumed + } else { + currentProse += line + "\n"; + } + } + + // Don't forget trailing prose + if (currentProse.trim()) { + segments.push({ type: "prose", content: currentProse }); + } + + return { frontmatter, imports, segments }; +} + +// Convert markdown to HTML for TipTap (basic conversion) +function markdownToHtml(markdown: string): string { + let html = markdown + // Headers + .replace(/^#### (.+)$/gm, "

$1

") + .replace(/^### (.+)$/gm, "

$1

") + .replace(/^## (.+)$/gm, "

$1

") + .replace(/^# (.+)$/gm, "

$1

") + // Bold and italic + .replace(/\*\*\*(.+?)\*\*\*/g, "$1") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/_(.+?)_/g, "$1") + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Wiki links - preserve as-is for now + .replace(/\[\[([^\]]+)\]\]/g, "[[$1]]") + // Inline code + .replace(/`([^`]+)`/g, "$1") + // Horizontal rules + .replace(/^---$/gm, "
") + // Blockquotes + .replace(/^> (.+)$/gm, "

$1

") + // Unordered lists + .replace(/^- (.+)$/gm, "
  • $1
  • ") + // Ordered lists + .replace(/^\d+\. (.+)$/gm, "
  • $1
  • "); + + // Wrap consecutive
  • in
      or
        + html = html.replace(/(
      1. .*<\/li>\n?)+/g, (match) => `
          ${match}
        `); + + // Paragraphs - wrap remaining lines that aren't already wrapped + const lines = html.split("\n"); + const result: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + result.push(""); + } else if ( + trimmed.startsWith("${line}

        `); + } + } + + return result.join("\n"); +} + +// Convert TipTap HTML back to markdown +function htmlToMarkdown(html: string): string { + const div = document.createElement("div"); + div.innerHTML = html; + + function processNode(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ""; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const el = node as HTMLElement; + const children = Array.from(el.childNodes).map(processNode).join(""); + + switch (el.tagName.toLowerCase()) { + case "h1": + return `# ${children}\n\n`; + case "h2": + return `## ${children}\n\n`; + case "h3": + return `### ${children}\n\n`; + case "h4": + return `#### ${children}\n\n`; + case "p": + return `${children}\n\n`; + case "strong": + return `**${children}**`; + case "em": + return `*${children}*`; + case "a": + return `[${children}](${el.getAttribute("href") || ""})`; + case "code": + return `\`${children}\``; + case "blockquote": + return children + .split("\n") + .filter((l) => l.trim()) + .map((l) => `> ${l.replace(/^> /, "")}`) + .join("\n") + "\n\n"; + case "ul": + return children; + case "ol": + return children; + case "li": + return `- ${children.trim()}\n`; + case "hr": + return "---\n\n"; + case "br": + return "\n"; + default: + return children; + } + } + + return processNode(div).replace(/\n{3,}/g, "\n\n").trim(); +} + +// Sub-component for individual prose editors +function ProseEditor({ + initialContent, + onUpdate, +}: { + initialContent: string; + onUpdate: (html: string) => void; +}) { + const editor = useEditor({ + // Disable immediate render to avoid SSR hydration mismatches + immediatelyRender: false, + extensions: [ + StarterKit.configure({ + heading: { + levels: [1, 2, 3, 4], + }, + }), + Placeholder.configure({ + placeholder: "Start writing...", + }), + ], + content: markdownToHtml(initialContent), + editorProps: { + attributes: { + class: "prose-editor", + }, + }, + onUpdate: ({ editor }) => { + onUpdate(editor.getHTML()); + }, + }); + + return ; +} + +export default function PostEditor({ + initialContent, + slug, + collection, + filePath, + lastModified: initialLastModified, +}: PostEditorProps) { + const [parsed] = useState(() => + parseMdxContent(initialContent) + ); + const [saveStatus, setSaveStatus] = useState< + "idle" | "saving" | "saved" | "error" | "conflict" + >("idle"); + const [conflictData, setConflictData] = useState<{ + serverContent: string; + serverModified: number; + } | null>(null); + const lastModifiedRef = useRef(initialLastModified); + const editorContentsRef = useRef>(new Map()); + + // Initialize editor contents from parsed segments + useEffect(() => { + parsed.segments.forEach((segment, index) => { + if (segment.type === "prose") { + editorContentsRef.current.set(index, markdownToHtml(segment.content)); + } + }); + }, [parsed]); + + // Reconstruct MDX content from editors + const reconstructContent = useCallback(() => { + let body = ""; + + parsed.segments.forEach((segment, index) => { + if (segment.type === "component") { + body += segment.content + "\n\n"; + } else { + const html = editorContentsRef.current.get(index) || ""; + const markdown = htmlToMarkdown(html); + body += markdown + "\n\n"; + } + }); + + return parsed.frontmatter + parsed.imports + body.trim() + "\n"; + }, [parsed]); + + // Save function with conflict detection + const save = useCallback(async () => { + setSaveStatus("saving"); + + const content = reconstructContent(); + + try { + const response = await fetch("/api/save-post", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filePath, + content, + expectedLastModified: lastModifiedRef.current, + }), + }); + + const result = await response.json(); + + if (result.conflict) { + setSaveStatus("conflict"); + setConflictData({ + serverContent: result.serverContent, + serverModified: result.serverModified, + }); + } else if (result.success) { + lastModifiedRef.current = result.newLastModified; + setSaveStatus("saved"); + setTimeout(() => setSaveStatus("idle"), 2000); + } else { + setSaveStatus("error"); + } + } catch (error) { + console.error("Save failed:", error); + setSaveStatus("error"); + } + }, [filePath, reconstructContent]); + + // Force save (overwrite server version) + const forceSave = useCallback(async () => { + if (!conflictData) return; + + setSaveStatus("saving"); + const content = reconstructContent(); + + try { + const response = await fetch("/api/save-post", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filePath, + content, + force: true, + }), + }); + + const result = await response.json(); + + if (result.success) { + lastModifiedRef.current = result.newLastModified; + setConflictData(null); + setSaveStatus("saved"); + setTimeout(() => setSaveStatus("idle"), 2000); + } else { + setSaveStatus("error"); + } + } catch (error) { + console.error("Force save failed:", error); + setSaveStatus("error"); + } + }, [filePath, reconstructContent, conflictData]); + + // Load server version (discard local changes) + const loadServerVersion = useCallback(() => { + if (!conflictData) return; + // Force re-render of editors by reloading + window.location.reload(); + }, [conflictData]); + + // Keyboard shortcut for save + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + save(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [save]); + + // Update status display + useEffect(() => { + const statusEl = document.getElementById("save-status"); + if (statusEl) { + const messages = { + idle: "", + saving: "Saving...", + saved: "Saved!", + error: "Error saving", + conflict: "Conflict detected!", + }; + statusEl.textContent = messages[saveStatus]; + statusEl.style.color = + saveStatus === "error" || saveStatus === "conflict" + ? "var(--color-bright-crimson)" + : saveStatus === "saved" + ? "var(--color-sea-blue)" + : ""; + } + }, [saveStatus]); + + return ( +
        + {/* Conflict resolution modal */} + {conflictData && ( +
        +
        +

        File Changed Externally

        +

        + The file was modified outside the editor (probably in VS Code). + What would you like to do? +

        +
        + + + +
        +
        +
        + )} + + {/* Toolbar */} +
        + + โŒ˜S to save +
        + + {/* Editor content */} +
        +
        + {parsed.segments.map((segment, index) => { + if (segment.type === "component") { + return ( +
        + {segment.componentName} +
        +                    {segment.content.length > 200
        +                      ? segment.content.slice(0, 200) + "..."
        +                      : segment.content}
        +                  
        +
        + ); + } + + return ( + { + editorContentsRef.current.set(index, html); + }} + /> + ); + })} +
        +
        +
        + ); +} diff --git a/src/components/editor/editor-styles.css b/src/components/editor/editor-styles.css new file mode 100644 index 00000000..352507a0 --- /dev/null +++ b/src/components/editor/editor-styles.css @@ -0,0 +1,380 @@ +/* Editor Container */ +.editor-container { + max-width: 100%; + min-height: calc(100vh - 80px); +} + +.editor-toolbar { + display: flex; + align-items: center; + gap: var(--space-m); + padding: var(--space-s) var(--space-l); + background: var(--color-light-cream); + border-bottom: 1px solid var(--color-gray-200); + position: sticky; + top: 60px; + z-index: 99; +} + +.toolbar-hint { + font-family: var(--font-sans); + font-size: var(--font-size-xs); + color: var(--color-gray-500); +} + +/* Buttons */ +.btn { + font-family: var(--font-sans); + font-size: var(--font-size-xs); + padding: var(--space-8) var(--space-16); + border-radius: var(--border-radius-base); + border: none; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-bright-crimson); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-crimson); +} + +.btn-secondary { + background: var(--color-gray-200); + color: var(--color-black); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-gray-300); +} + +.btn-ghost { + background: transparent; + color: var(--color-gray-600); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--color-gray-100); +} + +/* Editor Content Area */ +.editor-content { + padding: var(--space-l) var(--space-m); + background: linear-gradient(var(--color-cream) 0, white 110px); +} + +/* Prose Wrapper - matches the blog's ProseWrapper.astro */ +.prose-wrapper { + display: grid; + grid-template-columns: 1fr min(72ch, 100%) 1fr; + font-size: var(--font-size-base); +} + +.prose-wrapper > * { + grid-column: 2; +} + +/* TipTap Editor Styling */ +.prose-editor { + outline: none; + font-family: var(--font-body); + color: var(--color-black); +} + +.prose-editor:focus { + outline: none; +} + +/* Paragraphs */ +.prose-editor p { + font-size: var(--font-size-base); + line-height: var(--leading-looser); + margin-bottom: var(--space-m); +} + +@media (max-width: 767px) { + .prose-editor p { + line-height: var(--leading-loose); + } +} + +/* Headings */ +.prose-editor h1 { + font-family: var(--font-serif); + font-size: var(--font-size-2xl); + font-weight: normal; + margin: var(--space-2xl) 0 var(--space-m); + line-height: var(--leading-tight); +} + +.prose-editor h2 { + font-family: var(--font-serif); + font-size: var(--font-size-xl); + font-weight: 100; + margin: var(--space-xl) 0 var(--space-m); + line-height: var(--leading-base); +} + +.prose-editor h3 { + font-family: var(--font-serif); + font-size: calc(var(--font-size-lg) / 1.1); + font-weight: 300; + line-height: var(--leading-base); + margin: var(--space-m) 0 var(--space-s); +} + +.prose-editor h4 { + font-family: var(--font-body); + font-size: var(--font-size-base); + font-weight: 700; + margin: var(--space-s) 0 var(--space-xs); +} + +/* Links */ +.prose-editor a { + color: var(--color-bright-crimson); + text-decoration: underline; + text-decoration-color: var(--color-crimson-20); + text-underline-offset: 2px; + transition: text-decoration-color 0.15s ease; +} + +.prose-editor a:hover { + text-decoration-color: var(--color-bright-crimson); +} + +/* Bold and Italic */ +.prose-editor strong { + font-weight: 600; +} + +.prose-editor em { + font-style: italic; +} + +/* Lists */ +.prose-editor ul, +.prose-editor ol { + padding: 0; + margin-top: 0; + margin-bottom: var(--space-m); +} + +.prose-editor ul { + list-style: none; +} + +.prose-editor ul > li { + margin-bottom: var(--space-xs); + line-height: var(--leading-loose); + margin-left: 2.5rem; + position: relative; +} + +.prose-editor ul > li::before { + content: ""; + display: inline-block; + width: 24px; + height: 24px; + margin-right: -1.5rem; + background-image: url("/images/leaf-icon.svg"); + background-size: contain; + background-repeat: no-repeat; + position: absolute; + top: 4px; + left: -2.5rem; +} + +.prose-editor ol > li { + margin-bottom: var(--space-xs); + line-height: var(--leading-looser); + margin-left: 2.5rem; +} + +/* Blockquotes */ +.prose-editor blockquote { + text-align: center; + padding: var(--space-m) 0 var(--space-xl); + border: none; + margin: 0; +} + +.prose-editor blockquote p { + text-align: center; + max-width: 30ch; + margin: var(--space-m) auto; + font-size: var(--font-size-lg); + line-height: var(--leading-base); + display: inline-block; +} + +.prose-editor blockquote::before, +.prose-editor blockquote::after { + content: ""; + display: block; + margin: 0 auto; + width: 3rem; + border-top: 2px solid rgba(0, 0, 0, 0.1); +} + +/* Horizontal Rules */ +.prose-editor hr { + margin: var(--space-xl) auto var(--space-2xl); + height: 3px; + background-color: var(--color-salmon); + border: none; + width: 20%; +} + +/* Code */ +.prose-editor code { + background: var(--color-cream); + padding: var(--space-4) var(--space-8); + border-radius: var(--border-radius-sm); + font-family: "IBM Plex Mono", "Dank Mono", "SF Mono", consolas, monospace; + font-size: calc(var(--font-size-base) * 0.85); +} + +.prose-editor pre { + background: var(--color-black); + color: var(--color-light-cream); + padding: var(--space-24) var(--space-32); + border-radius: var(--border-radius-base); + font-family: "IBM Plex Mono", "Dank Mono", "SF Mono", consolas, monospace; + font-size: calc(var(--font-size-sm) * 1.1); + line-height: var(--leading-loose); + overflow-x: auto; + margin: 0 0 var(--space-m); +} + +.prose-editor pre code { + background: none; + padding: 0; + font-size: inherit; +} + +/* Placeholder */ +.prose-editor p.is-editor-empty:first-child::before { + color: var(--color-gray-400); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +/* Component Placeholders */ +.component-placeholder { + background: var(--color-gray-100); + border: 2px dashed var(--color-gray-300); + border-radius: var(--border-radius-base); + padding: var(--space-m); + margin: var(--space-m) 0; + position: relative; +} + +.component-label { + font-family: var(--font-sans); + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-gray-600); + background: white; + padding: var(--space-4) var(--space-8); + border-radius: var(--border-radius-sm); + position: absolute; + top: calc(-1 * var(--space-12)); + left: var(--space-m); +} + +.component-preview { + font-family: "IBM Plex Mono", "Dank Mono", "SF Mono", consolas, monospace; + font-size: var(--font-size-xs); + color: var(--color-gray-500); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + max-height: 150px; + overflow: hidden; +} + +/* Conflict Modal */ +.conflict-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.conflict-modal { + background: white; + padding: var(--space-l); + border-radius: var(--border-radius-lg); + max-width: 500px; + width: 90%; + box-shadow: var(--box-shadow-lg); +} + +.conflict-modal h2 { + font-family: var(--font-sans); + font-size: var(--font-size-md); + font-weight: 600; + margin: 0 0 var(--space-s); + color: var(--color-bright-crimson); +} + +.conflict-modal p { + font-family: var(--font-body); + font-size: var(--font-size-base); + line-height: var(--leading-base); + color: var(--color-gray-800); + margin: 0 0 var(--space-m); +} + +.conflict-actions { + display: flex; + flex-direction: column; + gap: var(--space-s); +} + +.conflict-actions .btn { + width: 100%; + padding: var(--space-12) var(--space-16); +} + +/* Responsive */ +@media (max-width: 767px) { + .editor-toolbar { + padding: var(--space-s); + } + + .editor-content { + padding: var(--space-m) var(--space-s); + } + + .prose-editor h1 { + margin: var(--space-xl) 0 var(--space-m); + } + + .prose-editor h2 { + margin: var(--space-l) 0 var(--space-s); + } + + .prose-editor hr { + margin: var(--space-l) auto; + } +} diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 08012366..c2b9d451 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -1,4 +1,7 @@ --- +// This page should be prerendered (static) since it uses getStaticPaths +export const prerender = true; + import PostLayout from "../layouts/PostLayout.astro"; import SmidgeonLayout from "../layouts/SmidgeonLayout.astro"; import ProseWrapper from "../components/layouts/ProseWrapper.astro"; diff --git a/src/pages/api/save-post.ts b/src/pages/api/save-post.ts new file mode 100644 index 00000000..e769ee0e --- /dev/null +++ b/src/pages/api/save-post.ts @@ -0,0 +1,84 @@ +import type { APIRoute } from "astro"; +import fs from "fs/promises"; + +// This endpoint should render on-demand, not be prerendered +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + // Only allow in dev mode + if (import.meta.env.PROD) { + return new Response( + JSON.stringify({ error: "API only available in development mode" }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + try { + const body = await request.json(); + const { filePath, content, expectedLastModified, force } = body; + + if (!filePath || !content) { + return new Response( + JSON.stringify({ error: "Missing filePath or content" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Security check - ensure we're only writing to content directory + if (!filePath.includes("/src/content/")) { + return new Response( + JSON.stringify({ error: "Invalid file path" }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + // Check for conflicts (unless force is true) + if (!force && expectedLastModified) { + try { + const stats = await fs.stat(filePath); + const currentModified = stats.mtimeMs; + + // If file was modified after our known version, there's a conflict + if (currentModified > expectedLastModified + 1000) { + // 1 second tolerance + const serverContent = await fs.readFile(filePath, "utf-8"); + return new Response( + JSON.stringify({ + conflict: true, + serverContent, + serverModified: currentModified, + message: "File was modified externally", + }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + } + } catch (error) { + // File might not exist, which is fine for new files + console.error("Error checking file stats:", error); + } + } + + // Write the file + await fs.writeFile(filePath, content, "utf-8"); + + // Get the new modification time + const newStats = await fs.stat(filePath); + + return new Response( + JSON.stringify({ + success: true, + newLastModified: newStats.mtimeMs, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Save error:", error); + return new Response( + JSON.stringify({ + error: "Failed to save file", + details: error instanceof Error ? error.message : "Unknown error", + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}; diff --git a/src/pages/edit/[...slug].astro b/src/pages/edit/[...slug].astro new file mode 100644 index 00000000..b7b47a13 --- /dev/null +++ b/src/pages/edit/[...slug].astro @@ -0,0 +1,140 @@ +--- +import fs from "fs/promises"; +import path from "path"; +import Layout from "../../layouts/Layout.astro"; +import PostEditor from "../../components/editor/PostEditor"; + +// This page should render on-demand, not be prerendered +export const prerender = false; + +// Only allow in dev mode +if (import.meta.env.PROD) { + return Astro.redirect("/"); +} + +const { slug } = Astro.params; + +if (!slug) { + return Astro.redirect("/"); +} + +// Find the file across all content collections +const collections = ["essays", "notes", "patterns", "talks", "smidgeons", "now"]; +let filePath: string | null = null; +let fileContent: string = ""; +let collection: string = ""; + +for (const col of collections) { + // Handle both flat files and versioned folders + const flatPath = path.join(process.cwd(), "src/content", col, `${slug}.mdx`); + const folderPath = path.join(process.cwd(), "src/content", col, slug, `${slug}.mdx`); + + try { + await fs.access(flatPath); + filePath = flatPath; + collection = col; + break; + } catch { + try { + await fs.access(folderPath); + filePath = folderPath; + collection = col; + break; + } catch { + // Continue to next collection + } + } +} + +if (!filePath) { + return Astro.redirect("/404"); +} + +// Read the file and get its modification time for conflict detection +const stats = await fs.stat(filePath); +fileContent = await fs.readFile(filePath, "utf-8"); +const lastModified = stats.mtimeMs; +--- + + +
        +
        + โ† Back to post +

        Editing: {slug}

        +
        + {collection} + +
        +
        + + +
        +
        + + diff --git a/src/pages/now-[slug].astro b/src/pages/now-[slug].astro index fb0d9f6d..0d6d2146 100644 --- a/src/pages/now-[slug].astro +++ b/src/pages/now-[slug].astro @@ -1,4 +1,7 @@ --- +// This page should be prerendered (static) since it uses getStaticPaths +export const prerender = true; + import { getCollection, getEntry } from "astro:content"; import Layout from "../layouts/Layout.astro"; import Title1 from "../components/mdx/typography/Title1.astro"; diff --git a/src/pages/og/[...slug].png.ts b/src/pages/og/[...slug].png.ts index 553985c8..ab3a0d57 100644 --- a/src/pages/og/[...slug].png.ts +++ b/src/pages/og/[...slug].png.ts @@ -1,3 +1,6 @@ +// This page should be prerendered (static) since it uses getStaticPaths +export const prerender = true; + import fs from "fs/promises"; import satori from "satori"; import sharp from "sharp"; diff --git a/src/pages/topics/[topic].astro b/src/pages/topics/[topic].astro index 7f445049..72c8723b 100644 --- a/src/pages/topics/[topic].astro +++ b/src/pages/topics/[topic].astro @@ -1,4 +1,7 @@ --- +// This page should be prerendered (static) since it uses getStaticPaths +export const prerender = true; + import Layout from "../../layouts/Layout.astro"; import Title2 from "../../components/mdx/typography/Title2.astro"; import TitleWithCount from "../../components/layouts/TitleWithCount.astro"; From 9b8b401e48870b09690099433866b7584b00b1c2 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Fri, 2 Jan 2026 09:29:55 +0000 Subject: [PATCH 2/4] built local dev editor for posts --- src/components/editor/PostEditor.tsx | 501 ++++++++++----------- src/components/editor/editor-styles.css | 432 ++++++++++-------- src/layouts/PostLayout.astro | 36 ++ src/pages/api/render-component.ts | 565 ++++++++++++++++++++++++ src/pages/edit/[...slug].astro | 253 +++++++++-- 5 files changed, 1312 insertions(+), 475 deletions(-) create mode 100644 src/pages/api/render-component.ts diff --git a/src/components/editor/PostEditor.tsx b/src/components/editor/PostEditor.tsx index dd5c486e..8543535b 100644 --- a/src/components/editor/PostEditor.tsx +++ b/src/components/editor/PostEditor.tsx @@ -1,6 +1,3 @@ -import { useEditor, EditorContent } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; -import Placeholder from "@tiptap/extension-placeholder"; import { useState, useEffect, useCallback, useRef } from "react"; import "./editor-styles.css"; @@ -12,42 +9,35 @@ interface PostEditorProps { lastModified: number; } -interface ParsedContent { - frontmatter: string; - imports: string; - segments: ContentSegment[]; -} - -interface ContentSegment { +interface Segment { type: "prose" | "component"; content: string; componentName?: string; } -// Parse MDX content into frontmatter, imports, and content segments -function parseMdxContent(content: string): ParsedContent { - const lines = content.split("\n"); - let frontmatter = ""; - let imports = ""; - const segments: ContentSegment[] = []; +interface ParsedContent { + prefix: string; // frontmatter + imports + segments: Segment[]; +} +// Parse content into prefix (frontmatter + imports) and segments +// Key: we preserve EXACT content including all whitespace +function parseContent(content: string): ParsedContent { + const lines = content.split("\n"); + let prefixEndLine = 0; let inFrontmatter = false; let frontmatterEnded = false; - let importsEnded = false; - let currentProse = ""; + // Find where content starts (after frontmatter and imports) for (let i = 0; i < lines.length; i++) { const line = lines[i]; - // Handle frontmatter if (i === 0 && line.trim() === "---") { inFrontmatter = true; - frontmatter += line + "\n"; continue; } if (inFrontmatter) { - frontmatter += line + "\n"; if (line.trim() === "---") { inFrontmatter = false; frontmatterEnded = true; @@ -55,69 +45,89 @@ function parseMdxContent(content: string): ParsedContent { continue; } - // Handle imports (lines starting with "import") - if (frontmatterEnded && !importsEnded) { + if (frontmatterEnded) { + // Skip import lines and empty lines in import section if (line.trim().startsWith("import ") || line.trim() === "") { - imports += line + "\n"; continue; } else { - importsEnded = true; + prefixEndLine = i; + break; } } + } + + // Build the prefix string (frontmatter + imports) + let prefix = ""; + for (let i = 0; i < prefixEndLine; i++) { + prefix += lines[i] + "\n"; + } + + // Now parse the rest into segments + const segments: Segment[] = []; + let i = prefixEndLine; - // Now we're in the content area - detect block-level MDX components - // A block component starts at the beginning of a line with (self-closing) - const selfClosingMatch = line.match(new RegExp(`^<${componentName}[^>]*/>`)); - if (selfClosingMatch) { + // Self-closing tag? + if (line.match(new RegExp(`^<${componentName}[^>]*/>`))) { segments.push({ type: "component", content: componentContent, componentName, }); + i++; continue; } - // Check if it opens AND closes on the same line - // Match pattern: ... - const sameLineCloseMatch = line.match(new RegExp(``)); - if (sameLineCloseMatch) { + // Opens and closes on same line? + if (line.match(new RegExp(``))) { segments.push({ type: "component", content: componentContent, componentName, }); + i++; continue; } - // Multi-line component - find closing tag - // We need to track depth for nested same-name components - let depth = 1; + // Multi-line component - need to find the closing tag or self-close let j = i + 1; + let foundEnd = false; + let inOpeningTag = true; // We're still inside the opening or /> - while (j < lines.length && depth > 0) { + while (j < lines.length && !foundEnd) { componentContent += "\n" + lines[j]; - // Count opening tags on this line (excluding self-closing) - // Look for , or end of pattern - const openMatches = lines[j].match(new RegExp(`<${componentName}(?:\\s|>)`, "g")) || []; - const selfCloseMatches = lines[j].match(new RegExp(`<${componentName}[^>]*/>`,"g")) || []; - const closeMatches = lines[j].match(new RegExp(``, "g")) || []; + if (inOpeningTag) { + // Still looking for the end of the opening tag + // It could end with > (content follows) or /> (self-closing) + if (lines[j].trim().endsWith("/>")) { + // Self-closing tag complete + foundEnd = true; + } else if (lines[j].includes(">")) { + // Opening tag closed, now look for closing tag + inOpeningTag = false; + // But also check if closing tag is on same line + if (lines[j].includes(``)) { + foundEnd = true; + } + } + } else { + // Looking for closing tag + // Simple approach: just find the closing tag (doesn't handle deep nesting of same component) + if (lines[j].includes(``)) { + foundEnd = true; + } + } - depth += openMatches.length - selfCloseMatches.length - closeMatches.length; j++; } @@ -126,182 +136,156 @@ function parseMdxContent(content: string): ParsedContent { content: componentContent, componentName, }); - i = j - 1; // Skip the lines we've consumed + i = j; } else { - currentProse += line + "\n"; - } - } - - // Don't forget trailing prose - if (currentProse.trim()) { - segments.push({ type: "prose", content: currentProse }); - } + // This is a prose line - accumulate until we hit a component + let proseContent = line; + let j = i + 1; - return { frontmatter, imports, segments }; -} + while (j < lines.length) { + const nextLine = lines[j]; + // Check if next line starts a component + if (nextLine.match(/^<([A-Z][a-zA-Z0-9]*)/)) { + break; + } + proseContent += "\n" + nextLine; + j++; + } -// Convert markdown to HTML for TipTap (basic conversion) -function markdownToHtml(markdown: string): string { - let html = markdown - // Headers - .replace(/^#### (.+)$/gm, "

        $1

        ") - .replace(/^### (.+)$/gm, "

        $1

        ") - .replace(/^## (.+)$/gm, "

        $1

        ") - .replace(/^# (.+)$/gm, "

        $1

        ") - // Bold and italic - .replace(/\*\*\*(.+?)\*\*\*/g, "$1") - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/_(.+?)_/g, "$1") - // Links - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') - // Wiki links - preserve as-is for now - .replace(/\[\[([^\]]+)\]\]/g, "[[$1]]") - // Inline code - .replace(/`([^`]+)`/g, "$1") - // Horizontal rules - .replace(/^---$/gm, "
        ") - // Blockquotes - .replace(/^> (.+)$/gm, "

        $1

        ") - // Unordered lists - .replace(/^- (.+)$/gm, "
      2. $1
      3. ") - // Ordered lists - .replace(/^\d+\. (.+)$/gm, "
      4. $1
      5. "); - - // Wrap consecutive
      6. in
          or
            - html = html.replace(/(
          1. .*<\/li>\n?)+/g, (match) => `
              ${match}
            `); - - // Paragraphs - wrap remaining lines that aren't already wrapped - const lines = html.split("\n"); - const result: string[] = []; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - result.push(""); - } else if ( - trimmed.startsWith("${line}

            `); + segments.push({ + type: "prose", + content: proseContent, + }); + i = j; } } - return result.join("\n"); + return { prefix, segments }; } -// Convert TipTap HTML back to markdown -function htmlToMarkdown(html: string): string { - const div = document.createElement("div"); - div.innerHTML = html; +// Reconstruct full content from prefix and segments +function reconstructContent(prefix: string, segments: Segment[]): string { + return prefix + segments.map(s => s.content).join("\n") + "\n"; +} - function processNode(node: Node): string { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent || ""; - } +// Rendered component display +function RenderedComponent({ + componentCode, + componentName, + slug, +}: { + componentCode: string; + componentName: string; + slug: string; +}) { + const [html, setHtml] = useState(null); + const [loading, setLoading] = useState(true); - if (node.nodeType !== Node.ELEMENT_NODE) { - return ""; + useEffect(() => { + async function fetchRendered() { + try { + const response = await fetch("/api/render-component", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ componentCode, slug }), + }); + const result = await response.json(); + if (result.html) { + setHtml(result.html); + } + } catch (error) { + console.error("Failed to render component:", error); + } finally { + setLoading(false); + } } + fetchRendered(); + }, [componentCode, slug]); + + if (loading) { + return ( +
            + {componentName} +
            Loading...
            +
            + ); + } - const el = node as HTMLElement; - const children = Array.from(el.childNodes).map(processNode).join(""); - - switch (el.tagName.toLowerCase()) { - case "h1": - return `# ${children}\n\n`; - case "h2": - return `## ${children}\n\n`; - case "h3": - return `### ${children}\n\n`; - case "h4": - return `#### ${children}\n\n`; - case "p": - return `${children}\n\n`; - case "strong": - return `**${children}**`; - case "em": - return `*${children}*`; - case "a": - return `[${children}](${el.getAttribute("href") || ""})`; - case "code": - return `\`${children}\``; - case "blockquote": - return children - .split("\n") - .filter((l) => l.trim()) - .map((l) => `> ${l.replace(/^> /, "")}`) - .join("\n") + "\n\n"; - case "ul": - return children; - case "ol": - return children; - case "li": - return `- ${children.trim()}\n`; - case "hr": - return "---\n\n"; - case "br": - return "\n"; - default: - return children; - } + if (html) { + return ( +
            + ); } - return processNode(div).replace(/\n{3,}/g, "\n\n").trim(); + return ( +
            + {componentName} +
            +        {componentCode.length > 200 ? componentCode.slice(0, 200) + "..." : componentCode}
            +      
            +
            + ); } -// Sub-component for individual prose editors +// Prose editor - auto-resizing textarea function ProseEditor({ - initialContent, - onUpdate, + content, + onChange, }: { - initialContent: string; - onUpdate: (html: string) => void; + content: string; + onChange: (newContent: string) => void; }) { - const editor = useEditor({ - // Disable immediate render to avoid SSR hydration mismatches - immediatelyRender: false, - extensions: [ - StarterKit.configure({ - heading: { - levels: [1, 2, 3, 4], - }, - }), - Placeholder.configure({ - placeholder: "Start writing...", - }), - ], - content: markdownToHtml(initialContent), - editorProps: { - attributes: { - class: "prose-editor", - }, - }, - onUpdate: ({ editor }) => { - onUpdate(editor.getHTML()); - }, - }); - - return ; + const textareaRef = useRef(null); + const [localContent, setLocalContent] = useState(content); + + // Resize on mount and when content changes externally + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + } + }, []); + + // Sync if content prop changes (e.g., from reload) + useEffect(() => { + setLocalContent(content); + }, [content]); + + const handleChange = (e: React.ChangeEvent) => { + const newContent = e.target.value; + setLocalContent(newContent); + onChange(newContent); + + // Resize after content change + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; + } + }; + + return ( +