diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2025-09-30 13:34:10 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2025-09-30 13:37:25 +0200 |
| commit | e75d9e371269a4e46bffa6b3dd1fc7d040e82750 (patch) | |
| tree | c1f2a9aa8022a2710af5e5a577578855584540df /internal/mdedit | |
| parent | 0bb27576a37de4bf76dab35c73fa8790fcfa706a (diff) | |
| download | gofu-f8e0b3d1a52bae7500f4871cfcc855230216c279.tar.gz | |
add mdeditv2025.1
Diffstat (limited to 'internal/mdedit')
| -rw-r--r-- | internal/mdedit/main.go | 149 | ||||
| -rw-r--r-- | internal/mdedit/res/app.js | 67 | ||||
| -rw-r--r-- | internal/mdedit/res/index.html | 18 | ||||
| -rw-r--r-- | internal/mdedit/res/style.css | 48 |
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%; +} |
