web コンテナの土台を追加(HTTP サーバー、TVer API クライアント、htmx の画面枠、Dockerfile・compose.yaml)

This commit is contained in:
2026-10-03 17:01:50 +09:00
parent 4ad0d621e1
commit 5125614b93
15 changed files with 677 additions and 5 deletions
+43
View File
@@ -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
}
+63
View File
@@ -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)
}
})
}
}
+5
View File
@@ -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; }
File diff suppressed because one or more lines are too long
+13
View File
@@ -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.
+24
View File
@@ -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}}