summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2025-09-30 13:34:10 +0200
committerStefan Majewsky <majewsky@gmx.net>2025-09-30 13:37:25 +0200
commite75d9e371269a4e46bffa6b3dd1fc7d040e82750 (patch)
treec1f2a9aa8022a2710af5e5a577578855584540df /internal
parent0bb27576a37de4bf76dab35c73fa8790fcfa706a (diff)
downloadgofu-2025.1.tar.gz
add mdeditv2025.1
Diffstat (limited to 'internal')
-rw-r--r--internal/mdedit/main.go149
-rw-r--r--internal/mdedit/res/app.js67
-rw-r--r--internal/mdedit/res/index.html18
-rw-r--r--internal/mdedit/res/style.css48
4 files changed, 282 insertions, 0 deletions
diff --git a/internal/mdedit/main.go b/internal/mdedit/main.go
new file mode 100644
index 0000000..67cfdde
--- /dev/null
+++ b/internal/mdedit/main.go
@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: GPL-3.0-only
+
+package mdedit
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/renderer/html"
+)
+
+// Exec executes the mdedit applet and returns an exit code (0 for success, >0 for error).
+func Exec(args []string) int {
+ if len(args) != 2 {
+ os.Stderr.Write([]byte(" Usage: mdedit <file.md> <listenaddr>\nExample: mdedit todolist.md localhost:8080\n"))
+ return 1
+ }
+ markdownPath, listenAddress := args[0], args[1]
+
+ // prepare HTTP server
+ l := logic{markdownPath}
+ m := http.NewServeMux()
+ m.HandleFunc("GET /{$}", l.handleGetFile("index.html", embeddedHTML))
+ m.HandleFunc("GET /res.css", l.handleGetFile("res.css", embeddedCSS))
+ m.HandleFunc("GET /res.js", l.handleGetFile("res.js", embeddedJS))
+ m.HandleFunc("GET /data.html", l.handleGetDataHTML)
+ m.HandleFunc("GET /data.md", l.handleGetDataMarkdown)
+ m.HandleFunc("PUT /data.md", l.handlePutDataMarkdown)
+ s := &http.Server{Addr: listenAddress, Handler: m}
+
+ exitCode := &atomic.Int32{}
+ exitCode.Store(0)
+
+ // setup termination of HTTP server on SIGINT/SIGTERM
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ var wg sync.WaitGroup
+ wg.Go(func() {
+ for range ctx.Done() {
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ err := s.Shutdown(ctx)
+ if err != nil {
+ log.Println("shutdown error: " + err.Error())
+ exitCode.Store(1)
+ }
+ cancel()
+ })
+
+ // run HTTP server
+ log.Printf("listening on %s...\n", listenAddress)
+ err := s.ListenAndServe()
+ if err != http.ErrServerClosed {
+ log.Println("listen error: " + err.Error())
+ exitCode.Store(1)
+ }
+ stop()
+ wg.Wait()
+
+ return int(exitCode.Load())
+}
+
+type logic struct {
+ MarkdownPath string
+}
+
+var (
+ //go:embed res/index.html
+ embeddedHTML []byte
+ //go:embed res/style.css
+ embeddedCSS []byte
+ //go:embed res/app.js
+ embeddedJS []byte
+)
+
+// Handles `GET /` and `GET /res/...`.
+func (l logic) handleGetFile(name string, contents []byte) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(contents))
+ }
+}
+
+// Handles `GET /data.html`.
+func (l logic) handleGetDataHTML(w http.ResponseWriter, r *http.Request) {
+ buf, err := os.ReadFile(l.MarkdownPath)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ l.respondByRenderingMarkdown(w, buf)
+}
+
+// Handles `GET /data.md`.
+func (l logic) handleGetDataMarkdown(w http.ResponseWriter, r *http.Request) {
+ buf, err := os.ReadFile(l.MarkdownPath)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("X-Mdedit-Path", l.MarkdownPath)
+ http.ServeContent(w, r, "data.md", time.Time{}, bytes.NewReader(buf))
+}
+
+// Handles `PUT /data.md`.
+func (l logic) handlePutDataMarkdown(w http.ResponseWriter, r *http.Request) {
+ const maxSizeBytes = 8 * 1024 * 1024 // 8 MiB ought to be enough for a single Markdown file
+ buf, err := io.ReadAll(io.LimitReader(r.Body, maxSizeBytes))
+ if err != nil {
+ http.Error(w, "while reading request body: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ err = os.WriteFile(l.MarkdownPath, buf, 0666)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ l.respondByRenderingMarkdown(w, buf)
+}
+
+var md = goldmark.New(
+ goldmark.WithExtensions(extension.GFM),
+ goldmark.WithRendererOptions(html.WithUnsafe()),
+)
+
+func (l logic) respondByRenderingMarkdown(w http.ResponseWriter, source []byte) {
+ var buf bytes.Buffer
+ err := md.Convert(source, &buf)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(buf.Bytes())
+}
diff --git a/internal/mdedit/res/app.js b/internal/mdedit/res/app.js
new file mode 100644
index 0000000..c934697
--- /dev/null
+++ b/internal/mdedit/res/app.js
@@ -0,0 +1,67 @@
+(() => {
+ const $ = selector => document.querySelector(selector);
+
+ const handleResponse = (promise, action) => {
+ const error = promise.then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error, status = ${response.status} from ${response.url}`);
+ }
+ const title = response.headers.get("X-Mdedit-Path");
+ if (title !== null && title !== "") {
+ window.document.title = "mdedit: " + title;
+ $("span#title").innerText = title;
+ }
+ return response.text();
+ }).then(body => {
+ action(body);
+ return null;
+ }).catch((error) => {
+ return error;
+ }).then((error) => {
+ if (error != null) {
+ console.log(error);
+ $("span#status").innerText = `Error: ${error.message}`;
+ }
+ });
+ };
+
+ // handler for receiving a rendered HTML from the server
+ const receiveHTML = body => {
+ $("div#preview").innerHTML = body;
+ $("span#status").innerText = "Saved";
+ };
+
+ // whenever the <textedit> changes, wait 3 seconds and then save all changes
+ let timeoutID = null;
+ const onTextChange = event => {
+ if (timeoutID !== null) {
+ window.clearTimeout(timeoutID);
+ }
+ timeoutID = window.setTimeout(uploadMarkdown, 1000);
+ $("span#status").innerText = "Changed";
+ };
+ const uploadMarkdown = () => {
+ window.clearTimeout(timeoutID);
+ timeoutID = null;
+ $("span#status").innerText = "Saving...";
+
+ const opts = {
+ method: "PUT",
+ body: $("textarea#editor").value,
+ };
+ handleResponse(fetch("/data.md", opts), receiveHTML);
+ };
+
+ // load Markdown and rendered HTML on startup
+ handleResponse(fetch("/data.md"), body => {
+ const editor = $("textarea#editor");
+ $("textarea#editor").value = body;
+ handleResponse(fetch("/data.html"), receiveHTML);
+
+ // avoid a useless upload on startup by attaching this only after the initial load is done
+ for (const eventType of ["change", "input", "textInput"]) {
+ editor.addEventListener(eventType, onTextChange);
+ }
+ });
+
+})();
diff --git a/internal/mdedit/res/index.html b/internal/mdedit/res/index.html
new file mode 100644
index 0000000..20aea13
--- /dev/null
+++ b/internal/mdedit/res/index.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>mdedit</title>
+ <link rel="stylesheet" type="text/css" href="/res.css" />
+</head>
+<body>
+ <nav>
+ <span id="title">mdedit</span>: <span id="status">Loading...</span>
+ </nav>
+ <textarea id="editor"></textarea>
+ <div id="preview"></div>
+</body>
+<script src="/res.js"></script>
+</html>
diff --git a/internal/mdedit/res/style.css b/internal/mdedit/res/style.css
new file mode 100644
index 0000000..01cd0c2
--- /dev/null
+++ b/internal/mdedit/res/style.css
@@ -0,0 +1,48 @@
+:root {
+ height: 100vh;
+ overflow: none;
+}
+
+body {
+ display: grid;
+ margin: 0;
+ padding: 0;
+ grid-template-rows: 2rem 1fr;
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas: "nav nav" "editor preview";
+ overflow: hidden;
+ height: 100vh;
+ font-family: system-ui, sans-serif;
+
+ & > nav { grid-area: nav; }
+ & > textarea#editor { grid-area: editor; }
+ & > div#preview { grid-area: preview; }
+}
+
+nav {
+ height: 2rem;
+ line-height: 2rem;
+ padding: 0 1rem;
+ background: #111;
+ color: white;
+
+ & > span#title { font-weight: bold; }
+}
+
+textarea#editor {
+ margin: 0;
+ border: none;
+ background: #333;
+ color: white;
+
+ resize: none;
+ overflow-y: scroll;
+ height: 100%;
+}
+
+div#preview {
+ padding: 0 1rem;
+
+ overflow: scroll;
+ height: 100%;
+}