diff options
Diffstat (limited to 'website.go')
| -rw-r--r-- | website.go | 196 |
1 files changed, 185 insertions, 11 deletions
@@ -7,13 +7,16 @@ import ( "encoding/json" "errors" "fmt" + "html" "html/template" "io/fs" "log" + "maps" "net/http" "os" "regexp" "slices" + "sort" "strings" "sync" "time" @@ -25,7 +28,7 @@ import ( "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer/html" + gmhtml "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" "gopkg.in/yaml.v3" @@ -189,12 +192,39 @@ var md = goldmark.New( }), ), ), - goldmark.WithRendererOptions(html.WithUnsafe()), + goldmark.WithRendererOptions(gmhtml.WithUnsafe()), goldmark.WithParserOptions( parser.WithASTTransformers(util.Prioritized(externalLinks{}, 100)), ), ) +var tagRe = regexp.MustCompile(`<[^>]*>`) + +func ligatures(html string) string { + var out strings.Builder + depth, last := 0, 0 + apply := func(s string) string { + if depth == 0 { + return arrows.Replace(s) + } + return s + } + for _, loc := range tagRe.FindAllStringIndex(html, -1) { + out.WriteString(apply(html[last:loc[0]])) + tag := html[loc[0]:loc[1]] + switch { + case strings.HasPrefix(tag, "<pre"), strings.HasPrefix(tag, "<code"): + depth++ + case strings.HasPrefix(tag, "</pre"), strings.HasPrefix(tag, "</code"): + depth-- + } + out.WriteString(tag) + last = loc[1] + } + out.WriteString(apply(html[last:])) + return out.String() +} + type frontmatter struct { Title string `yaml:"title"` Desc string `yaml:"description"` @@ -234,7 +264,7 @@ func parseBlog(name, raw string) (Blog, error) { if err := md.Convert([]byte(body), &buf); err != nil { return Blog{}, err } - HTML := buf.String() + HTML := ligatures(buf.String()) slug := strings.TrimSuffix(name, ".md") date := frontmatter.Date // frontmatter wins @@ -321,6 +351,33 @@ func latestBlog(blogs []Blog) *Blog { type externalLinks struct{} +func newReplacer(pairs map[string]string) *strings.Replacer { + keys := make([]string, 0, len(pairs)) + for k := range pairs { + keys = append(keys, k) + } + slices.SortFunc(keys, func(a, b string) int { return cmp.Compare(len(b), len(a)) }) + + args := make([]string, 0, len(keys)*2) + for _, k := range keys { + args = append(args, html.EscapeString(k), pairs[k]) // rendered text has < and > escaped + } + return strings.NewReplacer(args...) +} + +var arrows = newReplacer(map[string]string{ + // arrows + "->": "→", "<-": "←", "<->": "↔", + "-->": "⟶", "<--": "⟵", "<-->": "⟷", + "->>": "↠", "<<-": "↞", "|->": "↦", + "~>": "↝", "<~": "↜", + // double arrows + "=>": "⇒", "<=>": "⇔", + "==>": "⟹", "<==": "⟸", "<==>": "⟺", + // comparisons + ">=": "≥", "<=": "≤", "!=": "≠", "+-": "±", +}) + func openInNewTab(n ast.Node) { n.SetAttributeString("target", []byte("_blank")) n.SetAttributeString("rel", []byte("noopener noreferrer")) @@ -346,12 +403,101 @@ func (externalLinks) Transform(doc *ast.Document, reader text.Reader, pc parser. }) } +type ShellResult struct { + Output templ.Component // nil renders nothing + Clear bool + Vars map[string]string + Cookies []*http.Cookie +} + +type shellCommand func(r *http.Request, args []string) ShellResult + +var shellCommands map[string]shellCommand + +func init() { + shellCommands = map[string]shellCommand{ + "fastfetch": func(*http.Request, []string) ShellResult { return ShellResult{Output: Fastfetch(fetchRows)} }, + "clear": func(*http.Request, []string) ShellResult { return ShellResult{Clear: true} }, + "help": func(*http.Request, []string) ShellResult { return ShellResult{Output: ShellHelp(shellNames())} }, + "export": func(r *http.Request, args []string) ShellResult { + if len(args) == 0 { + return ShellResult{Output: ShellText("usage: export KEY=value")} + } + res := ShellResult{Vars: map[string]string{}} + var lines []string + for _, arg := range args { + key, value, ok := strings.Cut(arg, "=") + if !ok { + lines = append(lines, "export: expected KEY=value, got "+arg) + continue + } + value = strings.Trim(value, `"'`) + vars, cookie, err := setEnvVar(key, value) + if err != nil { + lines = append(lines, "export: "+err.Error()) + continue + } + maps.Copy(res.Vars, vars) + res.Cookies = append(res.Cookies, cookie) + lines = append(lines, key+"="+value) + } + res.Output = ShellLines(lines) + return res + }, + } +} + +func setEnvVar(key, value string) (map[string]string, *http.Cookie, error) { + switch key { + case "THEME": + theme, ok := GetTheme(value) + if !ok { + return nil, nil, fmt.Errorf("unknown theme: %s", value) + } + return theme.Vars(), envCookie("theme", value), nil + case "FONT_PACK": + pack, ok := GetFontPack(value) + if !ok { + return nil, nil, fmt.Errorf("unknown font pack: %s", value) + } + return pack.Vars(), envCookie("font_pack", value), nil + default: + return nil, nil, fmt.Errorf("unknown variable: %s", key) + } +} + +func envCookie(name, value string) *http.Cookie { + return &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: 31536000, SameSite: http.SameSiteLaxMode} +} + +func shellNames() []string { + names := make([]string, 0, len(shellCommands)) + for name := range shellCommands { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func runShell(r *http.Request, line string) ShellResult { + fields := strings.Fields(line) + if len(fields) == 0 { + return ShellResult{} + } + run, ok := shellCommands[fields[0]] + if !ok { + return ShellResult{Output: ShellText(fields[0] + ": command not found")} + } + + return run(r, fields[1:]) +} + var projects = []Project{ { Slug: "krypton", Title: "Krypton", Category: "Programming Language Compiler", - Desc: "", + Desc: "Krypton is a simple, high-performance, general-purpose language with C-like control designed for good programmers.", // Icon: IconCpu("text-[#F5A670] self-center"), Icon: IconKrypton(), Tags: []ProjectTag{ @@ -376,6 +522,7 @@ var projects = []Project{ Icon: IconCpu("text-[#F16265]"), Tags: []ProjectTag{ {"Zig", Color("text-[#D8C44B]")}, + {"Raylib", Color("text-[#FFFFFF]")}, }, Links: []ProjectLink{ {IconGit("text-[#F5A670]"), "Git", "https://git.kyren.codes/kyren/cpu-vs-ai.git"}, @@ -500,7 +647,7 @@ func GetMyUptime() string { defer myUptime.mu.RUnlock() // If no ping received in 3 minutes, treat as offline - if time.Since(myUptime.LastUpdate) > 1*time.Minute { + if time.Since(myUptime.LastUpdate) > 3*time.Minute { return "Asleep" } @@ -519,7 +666,10 @@ func handleUptimePing(w http.ResponseWriter, r *http.Request) { }) } else if r.Method == http.MethodPost { if devMode == false { - secretToken := os.Getenv("MY_UPTIME_TOKEN") + secretFile := os.Getenv("MY_UPTIME_TOKEN_FILE") + bytes, err := os.ReadFile(secretFile) + Assert(err == nil) + secretToken := string(bytes) if r.Header.Get("Authorization") != "Bearer "+secretToken { http.Error(w, "Unauthorized", http.StatusUnauthorized) return @@ -602,7 +752,8 @@ func main() { minecraft := FontPack{ Monospace: `Monocraft, JetBrains Mono, monospace`, - Normal: `Monocraft, Inter, system-ui, sans-serif`, + // Normal: `Inter, Lower Pixel Regular, Monocraft, system-ui, sans-serif`, + Normal: `LowerPixelRegular, Inter, system-ui, sans-serif`, } SetTheme("carbonight-cat", carbonightCat) @@ -634,10 +785,6 @@ func main() { HomePage(currentStyle(r), latestBlog(getBlogs())).Render(r.Context(), w) }) - mux.HandleFunc("GET /info", func(w http.ResponseWriter, r *http.Request) { - InfoPage(currentStyle(r)).Render(r.Context(), w) - }) - mux.HandleFunc("GET /projects", func(w http.ResponseWriter, r *http.Request) { ProjectsPage(currentStyle(r), projects, 2).Render(r.Context(), w) }) @@ -655,6 +802,33 @@ func main() { BlogPage(currentStyle(r), *blog, prev, next).Render(r.Context(), w) }) + mux.HandleFunc("GET /shell", func(w http.ResponseWriter, r *http.Request) { + ShellPage(currentStyle(r)).Render(r.Context(), w) + }) + + mux.HandleFunc("POST /shell/run", func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<10) + line := strings.TrimSpace(r.FormValue("cmd")) + if len(line) > 200 { + line = line[:200] + } + res := runShell(r, line) + for _, c := range res.Cookies { + http.SetCookie(w, c) + } + + var buf bytes.Buffer + if res.Output != nil { + if err := ShellEntry(line, res.Output).Render(r.Context(), &buf); err != nil { + http.Error(w, "render failed", http.StatusInternalServerError) + return + } + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(map[string]any{"clear": res.Clear, "html": buf.String(), "vars": res.Vars}) + }) + mux.HandleFunc("GET /theme/{name}", styleHandler("theme", func(n string) (map[string]string, bool) { theme, ok := GetTheme(n) return theme.Vars(), ok |
