diff --git a/config/hyde.php b/config/hyde.php
index 0f83b8031a4..b05f8683950 100644
--- a/config/hyde.php
+++ b/config/hyde.php
@@ -418,6 +418,9 @@
// Should preview pages be saved to the output directory?
'save_preview' => true,
+ // Should the live edit feature be enabled?
+ 'live_edit' => env('SERVER_LIVE_EDIT', true),
+
// Configure the realtime compiler dashboard
'dashboard' => [
// Should the realtime compiler dashboard be enabled?
diff --git a/packages/framework/config/hyde.php b/packages/framework/config/hyde.php
index 0f83b8031a4..b05f8683950 100644
--- a/packages/framework/config/hyde.php
+++ b/packages/framework/config/hyde.php
@@ -418,6 +418,9 @@
// Should preview pages be saved to the output directory?
'save_preview' => true,
+ // Should the live edit feature be enabled?
+ 'live_edit' => env('SERVER_LIVE_EDIT', true),
+
// Configure the realtime compiler dashboard
'dashboard' => [
// Should the realtime compiler dashboard be enabled?
diff --git a/packages/realtime-compiler/resources/live-edit.blade.php b/packages/realtime-compiler/resources/live-edit.blade.php
new file mode 100644
index 00000000000..f75810cc616
--- /dev/null
+++ b/packages/realtime-compiler/resources/live-edit.blade.php
@@ -0,0 +1,31 @@
+
+
+ @php
+ /** @var \Hyde\Pages\Concerns\BaseMarkdownPage $page */
+ $markdown = $page->markdown()->body();
+ @endphp
+
+
+
+
+
+
+
diff --git a/packages/realtime-compiler/resources/live-edit.css b/packages/realtime-compiler/resources/live-edit.css
new file mode 100644
index 00000000000..400c8de0ef5
--- /dev/null
+++ b/packages/realtime-compiler/resources/live-edit.css
@@ -0,0 +1,63 @@
+#live-editor {
+ width: 100%;
+ height: 100%;
+ min-height: 300px;
+ border: none;
+ outline: none;
+ font-family: 'Source Code Pro', monospace;
+ padding: 1rem;
+ white-space: pre-line;
+}
+
+#live-editor:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ box-shadow: 0 0 0 0 rgba(255, 255, 255, 0), 0 0 0 calc(1px + 0px) rgba(37, 99, 235, 1), 0 0 #0000;
+ border-color: #2563eb;
+}
+
+#live-edit-container header {
+ width: 100%;
+ max-width: 100%;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+#live-edit-container menu button {
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.375rem;
+ font-weight: 500;
+ font-size: 0.75rem;
+ line-height: 1.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ cursor: pointer;
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, color 0.2s ease-in-out;
+}
+
+#liveEditCancel {
+ background-color: #e5e7eb;
+ border-color: #e5e7eb;
+ color: #1f2937;
+ margin-right: 0.35rem;
+}
+
+#liveEditCancel:hover {
+ background-color: #d1d5db;
+ border-color: #d1d5db;
+ color: #1f2937;
+}
+
+#liveEditSubmit {
+ background-color: #2563eb;
+ border-color: #2563eb;
+ color: #fff;
+}
+
+#liveEditSubmit:hover {
+ background-color: #1d4ed8;
+ border-color: #1d4ed8;
+ color: #fff;
+}
diff --git a/packages/realtime-compiler/resources/live-edit.js b/packages/realtime-compiler/resources/live-edit.js
new file mode 100644
index 00000000000..d58a91a10fe
--- /dev/null
+++ b/packages/realtime-compiler/resources/live-edit.js
@@ -0,0 +1,105 @@
+function initLiveEdit() {
+ function getArticle() {
+ let article = document.querySelector('#content > article');
+
+ if (article === null) {
+ // If no article element is found the user may have a custom template, so we cannot know which element to edit.
+ throw new Error('No article element found, cannot live edit. If you are using a custom template, please make sure to include an article element in the #content container.');
+ }
+
+ return article;
+ }
+
+ function getLiveEditor() {
+ return document.querySelector('#live-edit-container');
+ }
+
+ function showEditor() {
+ article.style.display = 'none';
+ getLiveEditor().style.display = '';
+ focusOnTextarea();
+ }
+
+ function hideEditor() {
+ article.style.display = '';
+ getLiveEditor().style.display = 'none';
+ }
+
+ function focusOnTextarea() {
+ const textarea = getLiveEditor().querySelector('textarea');
+
+ textarea.selectionStart = textarea.value.length;
+ textarea.focus();
+ }
+
+ function switchToEditor() {
+
+ function hasEditorBeenSetUp() {
+ return getLiveEditor() !== null;
+ }
+
+ function setupEditor() {
+ const template = document.getElementById('live-edit-template');
+ const article = getArticle();
+ let editor = document.importNode(template.content, true);
+ article.parentNode.insertBefore(editor, article.nextSibling);
+ editor = getLiveEditor();
+
+ // Apply CSS classes from article to editor to match layout
+ editor.classList.add(...article.classList);
+
+ showEditor();
+
+ document.getElementById('liveEditCancel').addEventListener('click', hideEditor);
+ }
+
+ if (hasEditorBeenSetUp()) {
+ showEditor();
+ } else {
+ setupEditor();
+ }
+ }
+
+ function handleShortcut(event) {
+ let isEditorHidden = getLiveEditor() === null || getLiveEditor().style.display === 'none';
+ let isEditorVisible = getLiveEditor() !== null && getLiveEditor().style.display !== 'none';
+
+ if (event.ctrlKey && event.key === 'e') {
+ event.preventDefault();
+
+ if (isEditorHidden) {
+ switchToEditor();
+ } else {
+ hideEditor();
+ }
+ }
+
+ if (event.ctrlKey && event.key === 's') {
+ if (isEditorVisible) {
+ event.preventDefault();
+
+ document.getElementById('liveEditSubmit').click();
+ }
+ }
+
+ if (event.key === 'Escape') {
+ if (isEditorVisible) {
+ event.preventDefault();
+
+ hideEditor();
+ }
+ }
+ }
+
+ function shortcutsEnabled() {
+ return localStorage.getItem('hydephp.live-edit.shortcuts') !== 'false';
+ }
+
+ const article = getArticle();
+
+ article.addEventListener('dblclick', switchToEditor);
+
+ if (shortcutsEnabled()) {
+ document.addEventListener('keydown', handleShortcut);
+ }
+}
diff --git a/packages/realtime-compiler/src/Http/LiveEditController.php b/packages/realtime-compiler/src/Http/LiveEditController.php
new file mode 100644
index 00000000000..a4de9543f51
--- /dev/null
+++ b/packages/realtime-compiler/src/Http/LiveEditController.php
@@ -0,0 +1,75 @@
+authorizePostRequest();
+
+ return $this->handleRequest();
+ }
+
+ protected function handleRequest(): HtmlResponse
+ {
+ $pagePath = $this->request->data['page'] ?? $this->abort(400, 'Must provide page path');
+ $content = $this->request->data['markdown'] ?? $this->abort(400, 'Must provide content');
+
+ $page = Hyde::pages()->getPage($pagePath);
+
+ if (! $page instanceof BaseMarkdownPage) {
+ $this->abort(400, 'Page is not a markdown page');
+ }
+
+ $page->markdown = new Markdown($content);
+ $page->save();
+
+ $this->writeToConsole("Updated file '$pagePath'", 'hyde@live-edit');
+
+ return $this->redirectToPage($page->getRoute());
+ }
+
+ public static function enabled(): bool
+ {
+ return config('hyde.server.live_edit', true);
+ }
+
+ public static function injectLiveEditScript(string $html): string
+ {
+ session_start();
+
+ return str_replace('