web コンテナの土台を追加(HTTP サーバー、TVer API クライアント、htmx の画面枠、Dockerfile・compose.yaml)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assets embed.FS
|
||||
|
||||
type page struct {
|
||||
Title string
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||
staticFiles, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
for path, title := range map[string]string{
|
||||
"GET /{$}": "新着",
|
||||
"GET /queue": "待ち行列",
|
||||
"GET /subscriptions": "登録番組",
|
||||
"GET /library": "ライブラリ",
|
||||
} {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
}
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
NewHandler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if response.Code != http.StatusOK || response.Body.String() != "ok\n" {
|
||||
t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesAndEmbeddedAssets(t *testing.T) {
|
||||
handler := NewHandler()
|
||||
for _, tt := range []struct{ path, title string }{
|
||||
{"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"},
|
||||
} {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("status=%d Content-Type=%q", response.Code, response.Header().Get("Content-Type"))
|
||||
}
|
||||
for _, want := range []string{"<h1>" + tt.title + "</h1>", "新着", "待ち行列", "登録番組", "ライブラリ", `hx-boost="true"`, "/static/htmx-2.0.11.min.js"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page missing %q", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/static/htmx-2.0.11.min.js", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Header().Get("Content-Type"), "javascript") || response.Body.Len() < 1000 || !strings.Contains(response.Body.String(), "htmx") {
|
||||
t.Errorf("embedded htmx: status=%d Content-Type=%q bytes=%d", response.Code, response.Header().Get("Content-Type"), response.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathsAndMethods(t *testing.T) {
|
||||
handler := NewHandler()
|
||||
for _, tt := range []struct {
|
||||
name, method, path string
|
||||
wantStatus int
|
||||
}{
|
||||
{"unknown page", http.MethodGet, "/missing", http.StatusNotFound},
|
||||
{"root prefix", http.MethodGet, "/missing/nested", http.StatusNotFound},
|
||||
{"missing static asset", http.MethodGet, "/static/missing.js", http.StatusNotFound},
|
||||
{"unsupported page method", http.MethodPost, "/queue", http.StatusMethodNotAllowed},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil))
|
||||
if response.Code != tt.wantStatus {
|
||||
t.Errorf("%s %s: status=%d; want %d", tt.method, tt.path, response.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
body { font-family: system-ui, sans-serif; margin: 0; color: #17212b; }
|
||||
header { display: flex; align-items: center; gap: 2rem; padding: 1rem 2rem; border-bottom: 1px solid #d6dee4; }
|
||||
nav { display: flex; flex-wrap: wrap; gap: 1.5rem; }
|
||||
nav a { color: #145a86; }
|
||||
main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
Zero-Clause BSD
|
||||
=============
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for
|
||||
any purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
|
||||
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
|
||||
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
|
||||
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
|
||||
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
||||
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} | TVer</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx-2.0.11.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<strong>TVer</strong>
|
||||
<nav aria-label="メイン" hx-boost="true">
|
||||
<a href="/">新着</a>
|
||||
<a href="/queue">待ち行列</a>
|
||||
<a href="/subscriptions">登録番組</a>
|
||||
<a href="/library">ライブラリ</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1>{{.Title}}</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
Reference in New Issue
Block a user