phase4: templUI-based frontend with HTMX-powered conversion form
This commit is contained in:
+78
-15
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -8,9 +9,11 @@ import (
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fserg/md-to-html/internal/converter"
|
||||
"github.com/fserg/md-to-html/internal/ui"
|
||||
"github.com/fserg/md-to-html/internal/version"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -69,37 +72,38 @@ func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("UI coming in phase 4"))
|
||||
_ = ui.Home().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) handleUIConvert(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.writeDecodeError(w, err)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxRequestBytes)
|
||||
if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil {
|
||||
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, "Слишком большой файл или ошибка формы")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.convertMarkdown(r.Form.Get("markdown"), r.Form.Get("title"))
|
||||
md, filename, err := s.readUIMarkdownPayload(r)
|
||||
if err != nil {
|
||||
s.writeConvertError(w, err)
|
||||
s.renderUIReadError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.conv.Convert(md, defaultDocumentTitle)
|
||||
if err != nil {
|
||||
s.log.Error("ui_convert_failed", "error", err)
|
||||
s.renderUIError(w, r, http.StatusBadGateway, "Ошибка конвертации: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
filename := htmlFilename(result.Title)
|
||||
previewID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
||||
downloadID := s.store.Put(result.HTML, "text/html; charset=utf-8", filename)
|
||||
|
||||
fragment := fmt.Sprintf(
|
||||
`<div><p>Result ready</p><a href="/preview/%s" target="_blank" rel="noopener">Preview</a> <a href="/download/%s">Download</a></div>`,
|
||||
previewID,
|
||||
downloadID,
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fragment))
|
||||
_ = ui.Result(previewID, downloadID, string(result.HTML), filename).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -237,6 +241,65 @@ func contentTypeOrDefault(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Server) renderUIError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = ui.Error(msg).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) renderUIReadError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var markdownTooLarge errMarkdownTooLarge
|
||||
|
||||
switch {
|
||||
case errors.Is(err, errEmptyMarkdown):
|
||||
s.renderUIError(w, r, http.StatusBadRequest, "Пустой markdown")
|
||||
case errors.As(err, &markdownTooLarge):
|
||||
s.renderUIError(w, r, http.StatusRequestEntityTooLarge, fmt.Sprintf("Markdown больше %d байт", s.cfg.MaxMarkdownBytes))
|
||||
default:
|
||||
s.renderUIError(w, r, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) readUIMarkdownPayload(r *http.Request) ([]byte, string, error) {
|
||||
switch r.FormValue("source") {
|
||||
case "", "file":
|
||||
file, header, err := r.FormFile("markdown_file")
|
||||
if err != nil {
|
||||
return nil, "", errors.New("Файл не загружен")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
markdown, err := io.ReadAll(io.LimitReader(file, s.cfg.MaxMarkdownBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("не удалось прочитать файл: %w", err)
|
||||
}
|
||||
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
||||
return markdown, htmlFilename(name), nil
|
||||
case "text":
|
||||
markdown := []byte(r.FormValue("markdown_text"))
|
||||
if err := validateMarkdown(markdown, s.cfg.MaxMarkdownBytes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return markdown, "document.html", nil
|
||||
default:
|
||||
return nil, "", errors.New("Неизвестный источник markdown")
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkdown(markdown []byte, limit int64) error {
|
||||
if int64(len(markdown)) > limit {
|
||||
return errMarkdownTooLarge{limit: limit}
|
||||
}
|
||||
if len(bytes.TrimSpace(markdown)) == 0 {
|
||||
return errEmptyMarkdown
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errEmptyMarkdown = errors.New("markdown must not be empty")
|
||||
|
||||
type errMarkdownTooLarge struct {
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/fserg/md-to-html/internal/converter"
|
||||
"github.com/fserg/md-to-html/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
@@ -48,6 +50,9 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/ui/convert", s.handleUIConvert)
|
||||
r.Get("/preview/{id}", s.handlePreview)
|
||||
r.Get("/download/{id}", s.handleDownload)
|
||||
if staticFS, err := fs.Sub(web.StaticFS, "static"); err == nil {
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServerFS(staticFS)))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -190,6 +192,201 @@ func TestStatusEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := newTestServer(t, defaultTestConfig())
|
||||
ts := httptest.NewServer(srv.Router())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := ts.Client().Get(ts.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("get home: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read home body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != "text/html; charset=utf-8" {
|
||||
t.Fatalf("content-type = %q, want %q", got, "text/html; charset=utf-8")
|
||||
}
|
||||
for _, needle := range []string{
|
||||
`hx-post="/ui/convert"`,
|
||||
`id="result"`,
|
||||
`value="file"`,
|
||||
`value="text"`,
|
||||
} {
|
||||
if !bytes.Contains(body, []byte(needle)) {
|
||||
t.Fatalf("home body missing %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIConvertWithText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := newTestServer(t, defaultTestConfig())
|
||||
ts := httptest.NewServer(srv.Router())
|
||||
defer ts.Close()
|
||||
|
||||
body, contentType := newMultipartRequest(t, map[string]string{
|
||||
"source": "text",
|
||||
"markdown_text": "# Привет мир\n\nТекст",
|
||||
}, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/ui/convert", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, respBody)
|
||||
}
|
||||
for _, needle := range []string{
|
||||
"Открыть превью",
|
||||
"Скачать HTML",
|
||||
`/preview/`,
|
||||
`/download/`,
|
||||
`srcdoc=`,
|
||||
`document.html`,
|
||||
} {
|
||||
if !bytes.Contains(respBody, []byte(needle)) {
|
||||
t.Fatalf("response missing %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIConvertWithFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := newTestServer(t, defaultTestConfig())
|
||||
ts := httptest.NewServer(srv.Router())
|
||||
defer ts.Close()
|
||||
|
||||
body, contentType := newMultipartRequest(t, map[string]string{
|
||||
"source": "file",
|
||||
}, map[string]filePart{
|
||||
"markdown_file": {
|
||||
filename: "guide.md",
|
||||
content: "# Guide\n\nBody",
|
||||
},
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/ui/convert", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, respBody)
|
||||
}
|
||||
if !bytes.Contains(respBody, []byte("guide.html")) {
|
||||
t.Fatalf("response missing filename; body=%s", respBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIConvertErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := newTestServer(t, Config{
|
||||
Addr: ":0",
|
||||
MaxMarkdownBytes: 8,
|
||||
MaxRequestBytes: 1024,
|
||||
PreviewTTL: time.Hour,
|
||||
ShutdownTimeout: time.Second,
|
||||
})
|
||||
ts := httptest.NewServer(srv.Router())
|
||||
defer ts.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]string
|
||||
files map[string]filePart
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "empty text",
|
||||
fields: map[string]string{"source": "text", "markdown_text": " "},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "Пустой markdown",
|
||||
},
|
||||
{
|
||||
name: "missing file",
|
||||
fields: map[string]string{"source": "file"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "Файл не загружен",
|
||||
},
|
||||
{
|
||||
name: "markdown too large",
|
||||
fields: map[string]string{"source": "text", "markdown_text": strings.Repeat("x", 9)},
|
||||
wantStatus: http.StatusRequestEntityTooLarge,
|
||||
wantBody: "Markdown больше 8 байт",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, contentType := newMultipartRequest(t, tc.fields, tc.files)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/ui/convert", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, tc.wantStatus, respBody)
|
||||
}
|
||||
if !bytes.Contains(respBody, []byte(tc.wantBody)) {
|
||||
t.Fatalf("response %q missing %q", respBody, tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewAndDownloadOneShot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -327,3 +524,41 @@ func defaultTestConfig() Config {
|
||||
ShutdownTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type filePart struct {
|
||||
filename string
|
||||
content string
|
||||
}
|
||||
|
||||
func newMultipartRequest(t *testing.T, fields map[string]string, files map[string]filePart) ([]byte, string) {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
for name, value := range fields {
|
||||
if err := writer.WriteField(name, value); err != nil {
|
||||
t.Fatalf("write field %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, file := range files {
|
||||
header := textproto.MIMEHeader{}
|
||||
header.Set("Content-Disposition", `form-data; name="`+name+`"; filename="`+file.filename+`"`)
|
||||
header.Set("Content-Type", "text/markdown")
|
||||
|
||||
part, err := writer.CreatePart(header)
|
||||
if err != nil {
|
||||
t.Fatalf("create part %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(part, file.content); err != nil {
|
||||
t.Fatalf("write part %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), writer.FormDataContentType()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user