diff options
Diffstat (limited to 'website.go')
| -rw-r--r-- | website.go | 158 |
1 files changed, 153 insertions, 5 deletions
@@ -1,11 +1,14 @@ package main import ( + "bytes" "cmp" "embed" "encoding/json" "errors" "fmt" + "html" + "html/template" "io/fs" "log" "net/http" @@ -17,6 +20,15 @@ import ( "time" "github.com/a-h/templ" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + gmhtml "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" "gopkg.in/yaml.v3" ) @@ -159,6 +171,56 @@ type Blog struct { Desc string Date time.Time Body string // raw markdown for rendering + HTML string // rendered body +} + +var md = goldmark.New( + goldmark.WithExtensions( + extension.GFM, + extension.Footnote, + highlighting.NewHighlighting( + highlighting.WithFormatOptions(chromahtml.WithClasses(true)), + highlighting.WithWrapperRenderer(func(w util.BufWriter, ctx highlighting.CodeBlockContext, entering bool) { + if entering { + lang, _ := ctx.Language() + fmt.Fprintf(w, `<div class="codeblock" data-lang="%s">`, template.HTMLEscapeString(string(lang))) + } else { + w.WriteString(`</div>`) + } + }), + ), + ), + 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 { @@ -167,6 +229,20 @@ type frontmatter struct { Date time.Time `yaml:"date"` } +func findBlog(blogs []Blog, slug string) (blog, prev, next *Blog) { + i := slices.IndexFunc(blogs, func(b Blog) bool { return b.Slug == slug }) + if i < 0 { + return nil, nil, nil + } + if i+1 < len(blogs) { + prev = &blogs[i+1] // older + } + if i > 0 { + next = &blogs[i-1] // newer + } + return &blogs[i], prev, next +} + var datePrefix = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-(.+)\.md$`) func parseBlog(name, raw string) (Blog, error) { @@ -182,6 +258,12 @@ func parseBlog(name, raw string) (Blog, error) { } } + var buf bytes.Buffer + if err := md.Convert([]byte(body), &buf); err != nil { + return Blog{}, err + } + HTML := ligatures(buf.String()) + slug := strings.TrimSuffix(name, ".md") date := frontmatter.Date // frontmatter wins if match := datePrefix.FindStringSubmatch(name); match != nil { @@ -204,6 +286,7 @@ func parseBlog(name, raw string) (Blog, error) { Date: date, Body: body, Desc: frontmatter.Desc, + HTML: HTML, }, nil } @@ -264,12 +347,66 @@ func latestBlog(blogs []Blog) *Blog { return &blogs[0] } +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")) +} + +func (externalLinks) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { + src := reader.Source() + ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch link := n.(type) { + case *ast.Link: + if bytes.HasPrefix(link.Destination, []byte("http")) { + openInNewTab(link) + } + case *ast.AutoLink: + if link.AutoLinkType == ast.AutoLinkURL && bytes.HasPrefix(link.URL(src), []byte("http")) { + openInNewTab(link) + } + } + return ast.WalkContinue, nil + }) +} + var projects = []Project{ { Slug: "krypton", Title: "Krypton", Category: "Programming Language Compiler", - Desc: "A modern Minecraft creator event. As a developer, I helped with minor improvements to the cosmetics system. ", + Desc: "", // Icon: IconCpu("text-[#F5A670] self-center"), Icon: IconKrypton(), Tags: []ProjectTag{ @@ -294,6 +431,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"}, @@ -514,13 +652,14 @@ func main() { } jetbrains := FontPack{ - Monospace: "JetBrains Mono", - Normal: `system-ui, sans-serif`, + Monospace: `JetBrains Mono, monospace`, + Normal: `Inter, system-ui, sans-serif`, } minecraft := FontPack{ - Monospace: `"Monocraft", "JetBrains Mono", monospace`, - Normal: `"Monocraft", system-ui, sans-serif`, + Monospace: `Monocraft, JetBrains Mono, monospace`, + // Normal: `Inter, Lower Pixel Regular, Monocraft, system-ui, sans-serif`, + Normal: `LowerPixelRegular, Inter, system-ui, sans-serif`, } SetTheme("carbonight-cat", carbonightCat) @@ -564,6 +703,15 @@ func main() { BlogsPage(currentStyle(r), getBlogs()).Render(r.Context(), w) }) + mux.HandleFunc("GET /blogs/{slug}", func(w http.ResponseWriter, r *http.Request) { + blog, prev, next := findBlog(getBlogs(), r.PathValue("slug")) + if blog == nil { + notFoundHandler(w, r) + return + } + BlogPage(currentStyle(r), *blog, prev, next).Render(r.Context(), w) + }) + mux.HandleFunc("GET /theme/{name}", styleHandler("theme", func(n string) (map[string]string, bool) { theme, ok := GetTheme(n) return theme.Vars(), ok |
