commit 3a4ec9cbf9be496a2ff676ac4a0971adadc844f2
parent 3b9c61b51a9e9aab44f9260d8642560e147cc4d1
Author: dwrz <dwrz@dwrz.net>
Date: Fri, 13 Jan 2023 00:54:51 +0000
Add web
Diffstat:
35 files changed, 2322 insertions(+), 0 deletions(-)
diff --git a/cmd/web/config/config.go b/cmd/web/config/config.go
@@ -0,0 +1,26 @@
+package config
+
+import (
+ _ "embed"
+ "encoding/json"
+ "fmt"
+
+ "code.dwrz.net/src/pkg/server"
+)
+
+//go:embed config.json
+var configuration []byte
+
+type Config struct {
+ Debug bool `json:"debug"`
+ Server *server.Config `json:"server"`
+}
+
+func New() (*Config, error) {
+ var cfg = &Config{}
+ if err := json.Unmarshal(configuration, cfg); err != nil {
+ return nil, fmt.Errorf("failed to parse config: %v", err)
+ }
+
+ return cfg, nil
+}
diff --git a/cmd/web/config/config.template.json b/cmd/web/config/config.template.json
@@ -0,0 +1,17 @@
+{
+ "debug": true,
+ "server": {
+ "certPath": "",
+ "keyPath": "",
+ "maxHeaderBytes": 1024,
+ "ports": {
+ "http": "8080"
+ },
+ "timeouts": {
+ "idle": 24000000000,
+ "read": 8000000000,
+ "shutdown": 30000000000,
+ "write": 16000000000
+ }
+ }
+}
diff --git a/cmd/web/main.go b/cmd/web/main.go
@@ -0,0 +1,61 @@
+package main
+
+import (
+ "os"
+ "os/signal"
+ "syscall"
+
+ "code.dwrz.net/src/cmd/web/config"
+ "code.dwrz.net/src/cmd/web/site"
+ "code.dwrz.net/src/pkg/log"
+ "code.dwrz.net/src/pkg/server"
+)
+
+func main() {
+ var l = log.New(os.Stderr)
+
+ // Get the config.
+ cfg, err := config.New()
+ if err != nil {
+ l.Error.Fatalf("failed to get config: %v", err)
+ }
+
+ // Setup the site.
+ site, err := site.New(site.Params{
+ Debug: cfg.Debug,
+ Log: l,
+ })
+ if err != nil {
+ l.Error.Fatalf("failed to setup API: %v", err)
+ }
+
+ // Setup the HTTP(S) server(s).
+ srv, err := server.New(server.Parameters{
+ Config: *cfg.Server,
+ Handler: site,
+ Log: l,
+ })
+ if err != nil {
+ l.Error.Fatalf("failed to create server: %v", err)
+ }
+
+ // Serve the site.
+ srv.Serve()
+
+ // Listen for OS signals.
+ osListener := make(chan os.Signal, 1)
+ signal.Notify(
+ osListener,
+ syscall.SIGTERM, syscall.SIGINT,
+ )
+
+ // Block until we receive a signal.
+ s := <-osListener
+ l.Debug.Printf("received signal: %s", s)
+
+ if err := srv.Shutdown(); err != nil {
+ l.Error.Fatalf("failed to shutdown server: %v", err)
+ }
+
+ l.Debug.Printf("terminating")
+}
diff --git a/cmd/web/site/entry/entry.go b/cmd/web/site/entry/entry.go
@@ -0,0 +1,149 @@
+package entry
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "code.dwrz.net/src/pkg/log"
+)
+
+//go:embed static/*
+var static embed.FS
+
+const (
+ YearFormat = "2006"
+ DateFormat = "2006-01-02"
+ metadataFile = "metadata.json"
+)
+
+type Entry struct {
+ Cover string `json:"cover"`
+ Content template.HTML `json:"content"`
+ Date time.Time `json:"date"`
+ Link string `json:"link"`
+ Next *Entry `json:"next"`
+ Previous *Entry `json:"previous"`
+ Published bool `json:"published"`
+ Title string `json:"title"`
+}
+
+type Year struct {
+ Entries []*Entry
+ Text string
+}
+
+type LoadParams struct {
+ Log *log.Logger
+}
+
+func Load(p LoadParams) ([]*Entry, error) {
+ var entries []*Entry
+
+ parseFS := func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() || d.Name() == "static" {
+ return nil
+ }
+
+ // Open the entry metadata file.
+ data, err := static.ReadFile(
+ filepath.Join("static", d.Name(), metadataFile),
+ )
+ if err != nil {
+ p.Log.Error.Printf("ignoring %s: %v", d.Name(), err)
+ return nil
+ }
+
+ var entry = &Entry{}
+ if err := json.Unmarshal(data, entry); err != nil {
+ p.Log.Error.Printf("ignoring %s: %v", d.Name(), err)
+ return nil
+ }
+
+ // Ignore unpublished entries.
+ if !entry.Published {
+ p.Log.Debug.Printf(
+ "ignoring %s: not published", d.Name(),
+ )
+ return nil
+ }
+
+ // Get the entry content.
+ entryFile := d.Name() + ".html"
+ content, err := static.ReadFile(
+ filepath.Join("static", d.Name(), entryFile),
+ )
+ if err != nil {
+ p.Log.Error.Printf("ignoring %s: %v", d.Name(), err)
+ return nil
+ }
+
+ // Set entry values.
+ entry.Content = template.HTML(string(content))
+ entry.Link = fmt.Sprintf("%s", entry.Date.Format(DateFormat))
+
+ entries = append(entries, entry)
+
+ return nil
+ }
+
+ if err := fs.WalkDir(static, "static", parseFS); err != nil {
+ return nil, fmt.Errorf("failed to parse templates: %v", err)
+ }
+
+ // Sort the entries.
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Date.Before(entries[j].Date)
+ })
+
+ // Set the previous and next entry.
+ for i, e := range entries {
+ if i-1 >= 0 {
+ e.Previous = entries[i-1]
+ }
+ if i+1 < len(entries) {
+ e.Next = entries[i+1]
+ }
+ }
+
+ return entries, nil
+}
+
+func SortYear(entries []*Entry) []Year {
+ var yearEntries = map[string]*Year{}
+ for _, e := range entries {
+ year := e.Date.Format(YearFormat)
+ if _, exists := yearEntries[year]; !exists {
+ yearEntries[year] = &Year{
+ Entries: []*Entry{e},
+ Text: year,
+ }
+ continue
+ }
+
+ yearEntries[year].Entries = append(yearEntries[year].Entries, e)
+ }
+
+ // Sort each year's entries, then sort the years.
+ var years = []Year{}
+ for _, year := range yearEntries {
+ sort.Slice(year.Entries, func(i, j int) bool {
+ return year.Entries[i].Date.After(year.Entries[j].Date)
+ })
+
+ years = append(years, *year)
+ }
+ sort.Slice(years, func(i, j int) bool {
+ return years[i].Text > years[j].Text
+ })
+
+ return years
+}
diff --git a/cmd/web/site/error.go b/cmd/web/site/error.go
@@ -0,0 +1,58 @@
+package site
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "runtime/debug"
+
+ "code.dwrz.net/src/cmd/web/site/page"
+)
+
+const (
+ defaultErrorMessage = "Sorry, something went wrong."
+)
+
+type Error struct {
+ Code int
+ Error error
+ Message string
+}
+
+func (s *Site) error(w http.ResponseWriter, r *http.Request, e *Error) {
+ var (
+ id = r.Context().Value("id").(string)
+ trace = string(debug.Stack())
+ )
+
+ // Log with the line number of the caller.
+ s.log.Error.Output(
+ 2,
+ fmt.Sprintf("%s → %d error: %v", id, e.Code, e.Error),
+ )
+ if s.debug {
+ s.log.Error.Output(2, fmt.Sprintf(
+ "%s → TRACE:\n%s\n", id, trace,
+ ))
+ }
+
+ var p = &bytes.Buffer{}
+ if err := s.tmpl.ExecuteTemplate(
+ p, "base", &page.Error{
+ Debug: s.debug,
+ Message: e.Message,
+ RequestId: id,
+ Text: e.Error.Error(),
+ Trace: trace,
+ },
+ ); err != nil {
+ s.log.Error.Printf("%s → failed to render page: %v", id, err)
+
+ w.WriteHeader(e.Code)
+ w.Write([]byte(defaultErrorMessage))
+ return
+ }
+
+ w.WriteHeader(e.Code)
+ w.Write(p.Bytes())
+}
diff --git a/cmd/web/site/new.go b/cmd/web/site/new.go
@@ -0,0 +1,107 @@
+package site
+
+import (
+ "bytes"
+ "fmt"
+
+ "code.dwrz.net/src/cmd/web/site/entry"
+ "code.dwrz.net/src/cmd/web/site/page"
+ "code.dwrz.net/src/cmd/web/site/templates"
+ "code.dwrz.net/src/pkg/log"
+)
+
+type Params struct {
+ Debug bool
+ Log *log.Logger
+}
+
+func (p *Params) Validate() error {
+ if p.Log == nil {
+ return fmt.Errorf("missing logger")
+ }
+
+ return nil
+}
+
+func New(p Params) (*Site, error) {
+ if err := p.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid params: %v", err)
+ }
+
+ var site = &Site{
+ debug: p.Debug,
+ files: static,
+ log: p.Log,
+ pages: map[string]*bytes.Buffer{},
+ }
+
+ // Load the templates.
+ tmpl, err := templates.Parse()
+ if err != nil {
+ return nil, fmt.Errorf("failed to load templates: %v", err)
+ }
+ site.tmpl = tmpl
+
+ // Load the entries.
+ entries, err := entry.Load(entry.LoadParams{Log: p.Log})
+ if err != nil {
+ return nil, fmt.Errorf("failed to load entries: %v", err)
+ }
+
+ // Render contact page.
+ var contact = &bytes.Buffer{}
+ if err := site.tmpl.ExecuteTemplate(
+ contact, "base", &page.Contact{},
+ ); err != nil {
+ return nil, fmt.Errorf("failed to exec template: %v", err)
+ }
+ site.pages["/contact/"] = contact
+
+ // Render cv page.
+ var cv = &bytes.Buffer{}
+ if err := site.tmpl.ExecuteTemplate(
+ cv, "base", &page.CV{},
+ ); err != nil {
+ return nil, fmt.Errorf("failed to exec template: %v", err)
+ }
+ site.pages["/cv/"] = cv
+
+ // Render the home page.
+ var home = &bytes.Buffer{}
+ if err := site.tmpl.ExecuteTemplate(
+ home, "base", &page.Home{},
+ ); err != nil {
+ return nil, fmt.Errorf("failed to exec template: %v", err)
+ }
+ site.pages["/"] = home
+
+ // Render the timeline page.
+ var timeline = &bytes.Buffer{}
+ if err := site.tmpl.ExecuteTemplate(
+ timeline, "base", &page.Timeline{
+ Years: entry.SortYear(entries),
+ },
+ ); err != nil {
+ return nil, fmt.Errorf("failed to exec template: %v", err)
+ }
+ site.pages["/timeline/"] = timeline
+
+ // Render the entry pages.
+ for _, e := range entries {
+ var p = &bytes.Buffer{}
+ if err := site.tmpl.ExecuteTemplate(
+ p, "base", &page.Entry{Entry: e},
+ ); err != nil {
+ return nil, fmt.Errorf(
+ "failed to exec template: %v", err,
+ )
+ }
+
+ path := fmt.Sprintf(
+ "/timeline/%s/", e.Date.Format(entry.DateFormat),
+ )
+ site.pages[path] = p
+ }
+
+ return site, nil
+}
diff --git a/cmd/web/site/page.go b/cmd/web/site/page.go
@@ -0,0 +1,27 @@
+package site
+
+import (
+ "fmt"
+ "net/http"
+)
+
+func (s *Site) page(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ if page := s.pages[r.URL.Path]; page != nil {
+ w.WriteHeader(http.StatusOK)
+ w.Write(page.Bytes())
+ return
+ }
+
+ s.error(w, r, &Error{
+ Code: http.StatusNotFound,
+ Error: fmt.Errorf("no page for %s", r.URL.Path),
+ Message: fmt.Sprintf(
+ "Sorry, but %s does not exist.", r.URL.Path,
+ ),
+ })
+}
diff --git a/cmd/web/site/page/page.go b/cmd/web/site/page/page.go
@@ -0,0 +1,51 @@
+package page
+
+import (
+ "code.dwrz.net/src/cmd/web/site/entry"
+)
+
+type Contact struct{}
+
+func (p *Contact) View() string {
+ return "contact"
+}
+
+type CV struct{}
+
+func (p *CV) View() string {
+ return "cv"
+}
+
+type Entry struct {
+ Entry *entry.Entry
+}
+
+func (p *Entry) View() string {
+ return "entry"
+}
+
+type Error struct {
+ Debug bool
+ Message string
+ RequestId string
+ Text string
+ Trace string
+}
+
+func (p *Error) View() string {
+ return "error"
+}
+
+type Home struct{}
+
+func (p *Home) View() string {
+ return "home"
+}
+
+type Timeline struct {
+ Years []entry.Year
+}
+
+func (p *Timeline) View() string {
+ return "timeline"
+}
diff --git a/cmd/web/site/site.go b/cmd/web/site/site.go
@@ -0,0 +1,86 @@
+package site
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "fmt"
+ "html/template"
+ "net/http"
+ "path"
+ "strings"
+ "time"
+
+ "code.dwrz.net/src/pkg/log"
+ "code.dwrz.net/src/pkg/randstr"
+)
+
+//go:embed all:static/*
+var static embed.FS
+
+type Site struct {
+ debug bool
+ files embed.FS
+ log *log.Logger
+ pages map[string]*bytes.Buffer
+ tmpl *template.Template
+}
+
+func (s *Site) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ now := time.Now()
+
+ // Recover and handle panics.
+ defer func() {
+ if err := recover(); err != nil {
+ w.Header().Set("Connection", "close")
+
+ s.error(w, r, &Error{
+ Code: http.StatusInternalServerError,
+ Error: fmt.Errorf("%s", err),
+ Message: "Sorry, something went wrong.",
+ })
+ }
+ }()
+
+ // Generate an id to track the request.
+ // Update the request context to store the id.
+ id := randstr.Charset(randstr.Numeric, 8)
+ r = r.WithContext(context.WithValue(r.Context(), "id", id))
+
+ // Log the request.
+ var origin = r.RemoteAddr
+ if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
+ origin = ip
+ }
+ s.log.Debug.Printf(
+ "%s → %s %s %s %s",
+ id, origin,
+ r.Proto, r.Method, r.URL.RequestURI(),
+ )
+
+ // Pass the request on to the multiplexer.
+ base, _ := shiftpath(r.URL.Path)
+ switch base {
+ case "static":
+ s.static(w, r)
+ case "status":
+ s.status(w, r)
+ default:
+ s.page(w, r)
+ }
+
+ s.log.Debug.Printf(
+ "%s → completed in %v", id, time.Since(now),
+ )
+}
+
+func shiftpath(p string) (base, rest string) {
+ p = path.Clean("/" + p)
+
+ i := strings.Index(p[1:], "/") + 1
+ if i <= 0 {
+ return p[1:], "/"
+ }
+
+ return p[1:i], p[i:]
+}
diff --git a/cmd/web/site/static.go b/cmd/web/site/static.go
@@ -0,0 +1,39 @@
+package site
+
+import (
+ "fmt"
+ "net/http"
+ "path/filepath"
+)
+
+func (s *Site) static(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ f, err := s.files.ReadFile(r.URL.Path[1:])
+ if err != nil {
+ s.error(w, r, &Error{
+ Code: http.StatusNotFound,
+ Error: fmt.Errorf("failed to open: %v", err),
+ Message: fmt.Sprintf(
+ "Failed to retrieve file: %s",
+ r.URL.Path,
+ ),
+ })
+ return
+ }
+
+ switch filepath.Ext(r.URL.Path) {
+ case ".css":
+ w.Header().Add("Content-Type", "text/css; charset=utf-8")
+ case ".js":
+ w.Header().Add("Content-Type", "text/javascript; charset=utf-8")
+ default:
+ w.Header().Add("Content-Type", http.DetectContentType(f))
+ }
+
+ w.WriteHeader(http.StatusOK)
+ w.Write(f)
+}
diff --git a/cmd/web/site/static/css/dwrz.css b/cmd/web/site/static/css/dwrz.css
@@ -0,0 +1,557 @@
+html {
+ background-color: #efefef;
+ color: #1d1f21;
+ font-family: serif;
+ margin: 1em;
+}
+
+body {
+ margin: 0em auto 0em auto;
+}
+
+main {
+ background-color: #ffffff;
+ margin: 1em auto 1em auto;
+}
+
+header {
+ background-color: #ffffff;
+ padding: 1em 0em 1em 0em;
+}
+
+footer {
+ background-color: #ffffff;
+ margin: 0em auto 1em auto;
+ padding: 1em 0em 1em 0em;
+}
+
+article {
+ line-height: 1.625;
+ margin: 0em auto 0em auto;
+ padding: 1em;
+ max-width: 95%;
+}
+
+section {
+ margin: 0em auto 0em auto;
+}
+
+
+/* Blocks */
+audio, video {
+ display: block;
+ height: 100%;
+ margin: 0 auto 1em auto;
+ max-width: 100;
+ text-align: center;
+ width: 100%;
+}
+
+aside {
+ color: #4d4d4c;
+ font-size: 0.75em;
+ width: 100%;
+}
+
+blockquote {
+ color: #282a2e;
+ font-family: serif;
+ margin: 0 auto 0 auto;
+ width: 80%;
+}
+
+canvas {
+ display: block;
+ margin: 0em auto 0em auto;
+ padding: 0em;
+}
+
+p {
+ font-size: 1.25em;
+}
+
+ol,
+ul {
+ font-size: 1.25em;
+ margin: 1em auto 1em auto;
+}
+
+h1 {
+ font-size: 2.5em;
+ hyphens: none;
+ margin: 0em;
+ text-align: center;
+}
+
+h2 {
+ font-size: 2em;
+ margin: 1em 0em 1em 0em;
+ text-align: center;
+}
+
+h3 {
+ font-size: 1.5em;
+ margin: 0em;
+}
+
+h4 {
+ font-size: 1em;
+ margin: 0em;
+}
+
+iframe {
+ border: 0;
+ height: 100%;
+ left: 0;
+ position: absolute;
+ top: 0;
+ width: 100%;
+}
+
+img {
+ height: auto;
+ max-width: 100%;
+}
+
+table {
+ border-collapse: collapse;
+ border: 3px solid;
+ max-width: 100%;
+ width: 100%;
+}
+
+td,
+th {
+ border: 1px solid;
+}
+
+/* Inline */
+a {
+ color: #4271ae;
+ text-decoration: none;
+}
+
+a:hover {
+ color: #3e999f;
+}
+
+a:visited {
+ color: #8959a8;
+ text-decoration: none;
+}
+
+blockquote > cite {
+ color: #4d4d4c;
+ margin: 0 auto 0 auto;
+}
+
+code {
+ font: 1em, monospace;
+}
+
+pre > code {
+ background-color: #efefef;
+ display: block;
+ padding: 0em 0.5em 0em 0.5em;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+/* Classes */
+.bold {
+ font-style: bold;
+}
+
+.bordered {
+ border: solid 3px;
+ box-sizing: border-box;
+ margin: 1em 0em 1em 0em;
+ max-width: 100%;
+ padding: 0em 1em 0em 1em;
+}
+
+.entry-nav {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ margin: 1em 0em 0em 0em;
+}
+
+.caption {
+ display: block;
+ font-size: 0.75em;
+ margin: 0 auto 0 auto;
+ text-align: center;
+ width: 80%;
+}
+
+.excerpt {
+ color: #222244;
+ display: block;
+ margin: 0 auto 0 auto;
+ font-size: 1em;
+ width: 100%;
+}
+
+.hlist li {
+ display:inline-block;
+}
+
+.hlist li:before {
+ content: "|";
+ font-size: 1em;
+}
+
+.hlist li:first-child:before {
+ content: '';
+}
+
+.nobullets {
+ list-style-type: none;
+}
+
+.img-center {
+ display: block;
+ margin: 1em auto 1em auto;
+ width: 100%;
+}
+
+.img-center-small {
+ display: block;
+ margin: 1em auto 1em auto;
+ width: 75%;
+}
+
+.img-round {
+ border-radius: 50%;
+}
+
+.italic {
+ font-style: italic;
+}
+
+.nav {
+ font-size: 1.25em;
+ text-align: center;
+}
+
+.poetry {
+ color: #4d4d4c;
+ display: block;
+ hyphens: none;
+ margin: 0 auto 0 auto;
+ width: 80%;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-large {
+ font-size: 1.5em;
+}
+
+.text-normal {
+ font-size: 1em;
+}
+
+.text-right {
+ text-align: right;
+}
+.text-small {
+ font-size: 0.75em;
+}
+
+.split-row {
+ display: grid;
+ grid-template-columns: auto auto;
+ max-width: 100%;
+}
+
+.timeline-row-single {
+ display: grid;
+ grid-column-gap: 1em;
+ grid-template-columns: 1fr;
+ line-height: 1.25;
+ margin: 1em 0em 0em 0em;
+ padding: 0;
+}
+
+.timeline-row {
+ display: grid;
+ grid-column-gap: 1em;
+ grid-template-columns: 1fr;
+ grid-template-rows: min-content;
+ line-height: 1.25;
+ margin: 1em 0em 0em 0em;
+ padding: 0;
+}
+
+.video-container {
+ padding-bottom: 56.25%; /* 16:9 */
+ position: relative;
+ width: 100%;
+}
+
+.wide64 {
+ max-width: 64ch;
+}
+
+.wide128 {
+ max-width: 128ch;
+}
+
+/* Background Colors */
+.bg-black {
+ background-color: #1d1f21;
+}
+
+.bg-blue {
+ background-color: #4271ae;
+}
+
+.bg-brown {
+ background-color: #a3685a;
+}
+
+.bg-cyan {
+ background-color: #3e999f;
+}
+
+.bg-gray0 {
+ background-color: #efefef;
+}
+
+.bg-gray1 {
+ background-color: #e0e0e0;
+}
+
+.bg-gray2 {
+ background-color: #d6d6d6;
+}
+
+.bg-gray3 {
+ background-color: #8e908c;
+}
+
+.bg-gray4 {
+ background-color: #969896;
+}
+
+.bg-gray5 {
+ background-color: #4d4d4c;
+}
+
+.bg-gray6 {
+ background-color: #282a2e;
+}
+
+.bg-green {
+ background-color: #718c00;
+}
+
+.bg-orange {
+ background-color: #f5871f;
+}
+
+.bg-purple {
+ background-color: #8959a8;
+}
+
+.bg-red {
+ background-color: #c82829;
+}
+
+.bg-white {
+ background-color: #ffffff;
+}
+
+.bg-yellow {
+ background-color: #eab700;
+}
+
+/* Border Colors */
+.br-black {
+ border-color: #1d1f21;
+}
+
+.br-blue {
+ border-color: #4271ae;
+}
+
+.br-brown {
+ border-color: #a3685a;
+}
+
+.br-cyan {
+ border-color: #3e999f;
+}
+
+.br-gray0 {
+ border-color: #efefef;
+}
+
+.br-gray1 {
+ border-color: #e0e0e0;
+}
+
+.br-gray2 {
+ border-color: #d6d6d6;
+}
+
+.br-gray3 {
+ border-color: #8e908c;
+}
+
+.br-gray4 {
+ border-color: #969896;
+}
+
+.br-gray5 {
+ border-color: #4d4d4c;
+}
+
+.br-gray6 {
+ border-color: #282a2e;
+}
+
+.br-green {
+ border-color: #718c00;
+}
+
+.br-orange {
+ border-color: #f5871f;
+}
+
+.br-purple {
+ border-color: #8959a8;
+}
+
+.br-red {
+ border-color: #c82829;
+}
+
+.br-white {
+ border-color: #ffffff;
+}
+
+.br-yellow {
+ border-color: #eab700;
+}
+
+/* Colors */
+.black {
+ color: #1d1f21;
+}
+
+.blue {
+ color: #4271ae;
+}
+
+.brown {
+ color: #a3685a;
+}
+
+.cyan {
+ color: #3e999f;
+}
+
+.gray0 {
+ color: #efefef;
+}
+
+.gray1 {
+ color: #e0e0e0;
+}
+
+.gray2 {
+ color: #d6d6d6;
+}
+
+.gray3 {
+ color: #8e908c;
+}
+
+.gray4 {
+ color: #969896;
+}
+
+.gray5 {
+ color: #4d4d4c;
+}
+
+.gray6 {
+ color: #282a2e;
+}
+
+.green {
+ color: #718c00;
+}
+
+.orange {
+ color: #f5871f;
+}
+
+.purple {
+ color: #8959a8;
+}
+
+.red {
+ color: #c82829;
+}
+
+.white {
+ color: #ffffff;
+}
+
+.yellow {
+ color: #eab700;
+}
+
+/* Media */
+@media print {
+ h1, h2, h3, h4, h5, h6 {
+ page-break-after: avoid;
+ }
+}
+
+@media only screen and (min-width: 1px) {
+ body {
+ max-width: 95%;
+ }
+}
+
+@media only screen and (min-width: 600px) {
+ body {
+ max-width: 95%;
+ }
+ .timeline-row {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media only screen and (min-width: 800px) {
+ body {
+ max-width: 80%;
+ }
+ .timeline-row {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media only screen and (min-width: 1000px) {
+ body {
+ max-width: 66%;
+ }
+ .timeline-row {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media only screen and (min-width: 1200px) {
+ body {
+ max-width: 50%;
+ }
+ .timeline-row {
+ grid-template-columns: 1fr 1fr;
+ }
+}
diff --git a/cmd/web/site/static/js/minotaur/direction.js b/cmd/web/site/static/js/minotaur/direction.js
@@ -0,0 +1,22 @@
+/* eslint-disable no-bitwise */
+
+export const up = 1 << 1;
+export const right = 1 << 2;
+export const down = 1 << 3;
+export const left = 1 << 4;
+export const clockwise = [up, right, down, left];
+
+// random returns a shuffled array of the four directions.
+export const random = () => [up, right, down, left].sort(
+ () => Math.random() - 0.5,
+);
+
+// opposite returns the direction opposite to the passed direction.
+export const opposite = (d) => {
+ if (d === up) return down;
+ if (d === right) return left;
+ if (d === down) return up;
+ if (d === left) return right;
+
+ throw new Error(`unrecognized direction ${d}`);
+};
diff --git a/cmd/web/site/static/js/minotaur/game.js b/cmd/web/site/static/js/minotaur/game.js
@@ -0,0 +1,137 @@
+import * as direction from '/static/js/minotaur/direction.js';
+
+// Game represents a maze game with player interaction.
+export default class Game {
+ constructor({ document, maze }) {
+ this.document = document;
+ this.escaped = false;
+ this.initialized = false;
+ this.intervals = { score: null, minotaur: null };
+ this.killed = false;
+ this.maze = maze;
+ this.pressed = {
+ down: false,
+ left: false,
+ right: false,
+ space: false,
+ up: false,
+ };
+ this.score = 0;
+
+ // Add the event listeners.
+ document.addEventListener('keydown', (e) => this.keyDownHandler(e), false);
+ document.addEventListener('keyup', (e) => this.keyUpHandler(e), false);
+ }
+
+ // End handles the game over condition.
+ end() {
+ // Clear the intervals.
+ clearInterval(this.intervals.score);
+ clearInterval(this.intervals.minotaur);
+
+ // Disable the event handlers.
+ this.document.removeEventListener('keydown', this.keyDownHandler, false);
+ this.document.removeEventListener('keyup', this.keyUpHandler, false);
+
+ // Display the game over text.
+ if (this.escaped) {
+ this.document.getElementById('game-over').innerHTML = `You escaped the labyrinth!\nScore: ${this.score}.`;
+ }
+ if (this.killed) {
+ this.document.getElementById('game-over').innerHTML = `You were slain by the Minotaur. Score: ${this.score}.`;
+ }
+
+ // Render the last frame.
+ return this.maze.render();
+ }
+
+ // init starts the game score and the Minotaur's movement.
+ init() {
+ // Start measuring the player's score.
+ this.intervals.score = setInterval(() => { this.score += 1; }, 1000);
+
+ // Move the minotaur.
+ this.intervals.minotaur = setInterval(() => this.maze.moveMinotaur(), 250);
+
+ this.initialized = true;
+ }
+
+ // keyDownHandler handles keydown events.
+ keyDownHandler(e) {
+ if (e.code === 'ArrowUp') {
+ this.pressed.up = true;
+ return e.preventDefault();
+ }
+ if (e.code === 'ArrowRight') {
+ this.pressed.right = true;
+ return e.preventDefault();
+ }
+ if (e.code === 'ArrowDown') {
+ this.pressed.down = true;
+ return e.preventDefault();
+ }
+ if (e.code === 'ArrowLeft') {
+ this.pressed.left = true;
+ return e.preventDefault();
+ }
+ if (e.code === 'Space') {
+ this.pressed.space = true;
+ return e.preventDefault();
+ }
+
+ return null;
+ }
+
+ // keyUpHandler handles keyup events.
+ keyUpHandler(e) {
+ if (e.code === 'ArrowUp') this.pressed.up = false;
+ if (e.code === 'ArrowRight') this.pressed.right = false;
+ if (e.code === 'ArrowDown') this.pressed.down = false;
+ if (e.code === 'ArrowLeft') this.pressed.left = false;
+ if (e.code === 'Space') this.pressed.space = false;
+ }
+
+ // run executes the game loop.
+ run() {
+ if (!this.initialized) this.init();
+
+ // Render the solution.
+ if (this.pressed.space) {
+ this.maze.setEscapePath();
+ } else {
+ this.maze.clearEscapePath();
+ }
+
+ // Move Theseus.
+ let dir = null;
+ if (this.pressed.up) dir = direction.up;
+ if (this.pressed.right) dir = direction.right;
+ if (this.pressed.down) dir = direction.down;
+ if (this.pressed.left) dir = direction.left;
+ if (dir) {
+ this.maze.moveTheseus({ d: dir });
+
+ // Prevent the user from moving more than one cell per keypress.
+ this.pressed.up = false;
+ this.pressed.right = false;
+ this.pressed.down = false;
+ this.pressed.left = false;
+ this.pressed.space = false;
+ }
+
+ // Render the maze.
+ this.maze.render();
+
+ // Check for game over conditions.
+ if (this.maze.escaped()) {
+ this.escaped = true;
+ return this.end();
+ }
+ if (this.maze.killed()) {
+ this.killed = true;
+ return this.end();
+ }
+
+ return requestAnimationFrame(() => this.run());
+ }
+}
diff --git a/cmd/web/site/static/js/minotaur/maze.js b/cmd/web/site/static/js/minotaur/maze.js
@@ -0,0 +1,370 @@
+/* eslint-disable no-bitwise, no-continue */
+import * as direction from '/static/js/minotaur/direction.js';
+
+// Maze represents a maze with:
+// - A player character, Theseus,
+// - An enemy, the Minotaur, which chases the player.
+// - An exit from the maze.
+export default class Maze {
+ constructor({ canvas, padding = 1, side = 24 }) {
+ this.canvas = canvas;
+ this.exit = null;
+ this.height = Math.floor((canvas.height - (padding * 2)) / side);
+ this.layout = [];
+ this.minotaur = null;
+ this.side = side;
+ this.solution = null;
+ this.theseus = null;
+ this.width = Math.floor((canvas.width - (padding * 2)) / side);
+
+ // Generate the maze.
+ for (let i = 0; i < this.height; i += 1) {
+ this.layout[i] = [];
+ for (let j = 0; j < this.width; j += 1) {
+ this.layout[i][j] = 0;
+ }
+ }
+ // Connect the cells.
+ const stack = [{ x: 0, y: 0 }];
+ const visited = { '0,0': true };
+ while (stack.length > 0) {
+ const current = stack[stack.length - 1];
+ let found = false;
+
+ const rd = direction.random();
+ for (const d of rd) {
+ // x and y are the coordinates of the neighboring cell.
+ const { x, y } = this.coordinateAtDirection(current, d);
+
+ // Ignore coordinates that are out of bounds.
+ if (!this.inBounds({ x, y })) {
+ continue;
+ }
+
+ // Check if we've already visited this cell.
+ // If we have, skip it.
+ const key = `${x},${y}`;
+ if (visited[key]) continue;
+
+ // If we've found a new cell, mark is at visited.
+ // Throw it on the stack to continue generating from it.
+ found = true;
+ visited[key] = true;
+ stack.push({ x, y });
+
+ // Connect the cells.
+ // Set the current cell.
+ this.layout[current.y][current.x] |= d;
+ // Set the neighbor.
+ this.layout[y][x] |= direction.opposite(d);
+
+ break;
+ }
+
+ // Remove expended cells.
+ if (!found) stack.pop();
+ }
+
+ // Set the location of the maze exit.
+ this.exit = this.randomCell();
+
+ // Set the location of Theseus.
+ this.theseus = this.randomCell();
+
+ // Set the location of the Minotaur.
+ this.minotaur = this.randomCell();
+ }
+
+ // clearEscapePath remove the maze solution.
+ clearEscapePath() {
+ this.solution = null;
+ }
+
+ // coordinateAtDirection returns the coordinates reached with the passed in
+ // starting point and direction.
+ coordinateAtDirection({ x, y }, d) {
+ if (d === direction.up) return { x, y: y - 1 };
+ if (d === direction.right) return { x: x + 1, y };
+ if (d === direction.down) return { x, y: y + 1 };
+ if (d === direction.left) return { x: x - 1, y };
+
+ throw new Error(`unrecognized direction ${d}`);
+ }
+
+ // escaped returns whether Theseus has reached the exit.
+ escaped() {
+ if (this.exit.x !== this.theseus.x) return false;
+ if (this.exit.y !== this.theseus.y) return false;
+ return true;
+ }
+
+ // hasDir returns whether a particular cell has a connection to the
+ // specified direction.
+ hasDir({ x, y }, d) {
+ return (this.layout[y][x] & d) !== 0;
+ }
+
+ // inBounds returns whether or not the provided coordinates exist in the maze.
+ inBounds({ x, y }) {
+ if (x < 0 || y < 0 || x >= this.width || y >= this.height) return false;
+ return true;
+ }
+
+ // killed returns whether the Minotaur has reached Theseus.
+ killed() {
+ if (this.minotaur.x !== this.theseus.x) return false;
+ if (this.minotaur.y !== this.theseus.y) return false;
+ return true;
+ }
+
+ // openDirections returns the open directions for a cell.
+ openDirections({ x, y }) {
+ const directions = [];
+
+ for (const d of direction.clockwise) {
+ if (this.hasDir({ x, y }, d)) {
+ directions.push(d);
+ }
+ }
+
+ return directions;
+ }
+
+ // moveMinotaur sets the new location of the Minotaur.
+ moveMinotaur() {
+ // const dirs = this.openDirections(this.minotaur);
+ // if (dirs.length === 1) { // Deadened
+ // this.lastPosition = this.minotaur;
+ // this.minotaur = this.coordinateAtDirection(this.minotaur, dirs[0]);
+ // return;
+ // }
+
+ // const randomDirections = dirs.sort(() => Math.random() - 0.5);
+ // for (const d of randomDirections) {
+ // // Don't go to the last position.
+ // const next = this.coordinateAtDirection(this.minotaur, d);
+ // if (this.lastPosition.x === next.x && this.lastPosition.y === next.y) {
+ // continue;
+ // }
+ // // Go to the new direction.
+ // this.lastPosition = this.minotaur;
+ // this.minotaur = next;
+ // return;
+ // }
+
+ const { path } = this.solve({ start: this.minotaur, end: this.theseus });
+ // Move the Minotaur to the next cell in the path.
+ if (path && path.length >= 2) this.minotaur = path[1];
+ }
+
+ // moveTheseus moves Theseus in the provided direction, if it is valid.
+ moveTheseus({ d }) {
+ const next = this.coordinateAtDirection(this.theseus, d);
+ const hasDir = this.hasDir(this.theseus, d);
+ const inBounds = this.inBounds(next);
+
+ if (hasDir && inBounds) this.theseus = next;
+ }
+
+ // randomCell returns a random cell in the maze.
+ randomCell() {
+ return {
+ x: Math.floor(Math.random() * this.width),
+ y: Math.floor(Math.random() * this.height),
+ };
+ }
+
+ // render draws the maze onto the canvas.
+ render() {
+ const ctx = this.canvas.getContext('2d');
+ const pad = Math.floor((this.canvas.width - (this.width * this.side)) / 2);
+ const thickness = Math.floor(this.side / 5);
+
+ // cellX and cellY track the top-right edge of the current cell to draw.
+ let cellX = pad;
+ let cellY = pad;
+
+ // Clear the canvas.
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // Draw the bottom and right edges.
+ // The top and left edges will be drawn per cell.
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = thickness;
+
+ // Bottom
+ ctx.beginPath();
+ ctx.moveTo(
+ cellX - Math.floor(thickness / 2),
+ cellY + (this.side * this.height),
+ );
+ ctx.lineTo(
+ cellX + (this.side * this.width) + Math.floor(thickness / 2),
+ cellY + (this.side * this.height),
+ );
+ ctx.stroke();
+ // Right
+ ctx.beginPath();
+ ctx.moveTo(
+ cellX + (this.side * this.width),
+ cellY - Math.floor(thickness / 2),
+ );
+ ctx.lineTo(
+ cellX + (this.side * this.width),
+ cellY + (this.side * this.height),
+ );
+ ctx.stroke();
+
+ // Draw the cells.
+ for (let y = 0; y < this.height; y += 1) {
+ for (let x = 0; x < this.width; x += 1) {
+ const cell = this.layout[y][x];
+
+ // Draw the cell borders.
+ // A top edge will work as a bottom edge, except on the bottom row.
+ // A left edge will work as a right edge, except on the right column.
+ // We've handled the special cases above.
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = thickness;
+
+ // Top
+ if ((cell & direction.up) === 0 || y === 0) {
+ ctx.beginPath();
+ ctx.moveTo(cellX, cellY);
+ ctx.lineTo(cellX + this.side, cellY);
+ ctx.stroke();
+ }
+
+ // Left
+ if ((cell & direction.left) === 0 || x === 0) {
+ ctx.beginPath();
+ // Extend the edge on the upper-left cell to draw in the corner.
+ if (y === 0) {
+ ctx.moveTo(cellX, cellY - Math.floor(thickness / 2));
+ } else {
+ ctx.moveTo(cellX, cellY);
+ }
+ ctx.lineTo(cellX, cellY + this.side);
+ ctx.stroke();
+ }
+
+ // Fill the cell area.
+ ctx.beginPath();
+ if (this.solution !== null && this.solution.cells[`${x},${y}`]) {
+ ctx.fillStyle = '#4271ae';
+ } else {
+ ctx.fillStyle = '#efefef';
+ }
+ ctx.lineWidth = 1;
+ ctx.rect(cellX, cellY, this.side, this.side);
+ ctx.fill();
+
+ // Extend the bottom right edge on cells connected down and right.
+ // This needs to be done after the cell area is filled, or this
+ // line will be drawn over.
+ if ((cell & direction.down) !== 0 && (cell & direction.right) !== 0) {
+ ctx.lineWidth = thickness;
+ ctx.beginPath();
+ ctx.moveTo(
+ cellX + (this.side - Math.floor(thickness / 2)),
+ cellY + this.side,
+ );
+ ctx.lineTo(cellX + this.side, cellY + this.side);
+ ctx.stroke();
+ ctx.lineWidth = 1;
+ }
+
+ // Draw special cell states -- exit, Theseus, Minotaur.
+ // The order matters here -- if these overlap, they are drawn over
+ // each other.
+ if (x === this.exit.x && y === this.exit.y) {
+ ctx.beginPath();
+ ctx.fillStyle = '#718c00';
+ ctx.rect(
+ cellX + Math.floor(this.side / 4),
+ cellY + Math.floor(this.side / 4),
+ Math.floor(this.side / 2),
+ Math.floor(this.side / 2),
+ );
+ ctx.fill();
+ }
+
+ // Draw Theseus.
+ if (x === this.theseus.x && y === this.theseus.y) {
+ ctx.beginPath();
+ ctx.fillStyle = '#8959a8';
+ ctx.arc(
+ cellX + Math.floor(this.side / 2),
+ cellY + Math.floor(this.side / 2),
+ Math.floor(this.side / 4),
+ 0,
+ Math.PI * 2,
+ );
+ ctx.fill();
+ }
+
+ // Draw the Minotaur.
+ if (x === this.minotaur.x && y === this.minotaur.y) {
+ ctx.beginPath();
+ ctx.fillStyle = '#c82829';
+ ctx.arc(
+ cellX + Math.floor(this.side / 2),
+ cellY + Math.floor(this.side / 2),
+ Math.floor(this.side / 4),
+ 0,
+ Math.PI * 2,
+ );
+ ctx.fill();
+ }
+
+ // Increment to the next cell.
+ cellX += this.side;
+ }
+
+ // Increment to the next row.
+ cellX = pad;
+ cellY += this.side;
+ }
+ }
+
+ // setEscapePath sets the solution for the maze.
+ setEscapePath() {
+ this.solution = this.solve({ start: this.theseus, end: this.exit });
+ }
+
+ // solve uses breadth-first search to find a path between start and end.
+ solve({ start, end }) {
+ const q = [{ path: [start] }];
+
+ while (q.length > 0) {
+ const search = q.shift();
+ const { x, y } = search.path[search.path.length - 1];
+
+ // If we've reach the end, we're done.
+ if (x === end.x && y === end.y) {
+ return {
+ path: search.path,
+ cells: search.path.reduce((acc, v) => { acc[`${v.x},${v.y}`] = true; return acc; }, {}),
+ };
+ }
+
+ // Find the next cell.
+ // If the cell is in bounds and not a backtrack, add it to the path.
+ // Then, add the candidate path to the queue.
+ const cell = this.layout[y][x];
+ let previous = { x: null, y: null };
+ if (search.path.length > 2) previous = search.path[search.path.length - 2];
+
+ for (const d of direction.clockwise) {
+ if ((cell & d) !== 0) {
+ const next = this.coordinateAtDirection({ x, y }, d);
+ const inBounds = this.inBounds(next);
+ const notPrevious = !(next.x === previous.x && next.y === previous.y);
+ if (inBounds && notPrevious) q.push({ path: [...search.path, next] });
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/cmd/web/site/static/public.key b/cmd/web/site/static/public.key
@@ -0,0 +1,51 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+Snd72y4=
+=MO/l
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/cmd/web/site/status.go b/cmd/web/site/status.go
@@ -0,0 +1,14 @@
+package site
+
+import (
+ "net/http"
+)
+
+func (s *Site) status(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/cmd/web/site/templates/static/base.gohtml b/cmd/web/site/templates/static/base.gohtml
@@ -0,0 +1,16 @@
+{{ define "base" }}
+ <!doctype html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport"
+ content="width=device-width, initial-scale=1, shrink-to-fit=no">
+ <title>dwrz.net</title>
+ <meta name="author" content="David Wen Riccardi-Zhu">
+ <link rel="stylesheet" href="/static/css/dwrz.css"/>
+ </head>
+ <body>
+ {{ template "body" . }}
+ </body>
+ </html>
+{{ end }}
diff --git a/cmd/web/site/templates/static/body.gohtml b/cmd/web/site/templates/static/body.gohtml
@@ -0,0 +1,5 @@
+{{ define "body" }}
+ {{ template "header" . }}
+ {{ template "main" . }}
+ {{ template "footer" . }}
+{{ end }}
diff --git a/cmd/web/site/templates/static/contact.gohtml b/cmd/web/site/templates/static/contact.gohtml
@@ -0,0 +1,11 @@
+{{ define "contact" }}
+ <article class="wide64">
+ <p>
+ <a href="mailto:dwrz@dwrz.net">dwrz@dwrz.net</a>
+ <br>
+ <a href="/static/public.key">
+ 30EA CE33 766E 650E 2A39 DE30 FCA6 F285 5DD5 4633
+ </a>
+ </p>
+ </article>
+{{ end }}
diff --git a/cmd/web/site/templates/static/cv-education.gohtml b/cmd/web/site/templates/static/cv-education.gohtml
@@ -0,0 +1,60 @@
+{{ define "cv-education" }}
+ <h2 class="bg-blue white" id="education">Education</h2>
+ <div class="split-row">
+ <h3>Self-directed Studies</h3>
+ <h3 class="text-right">
+ <a href="https://www.recurse.com/">Recurse Center</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">October 2022 – February 2023 (W1 2022)</h4>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Advanced Software Engineering Immersive</h3>
+ <h3 class="text-right">
+ <a href="https://www.hackreactor.com/">Hack Reactor</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">October 2017 – January 2018</h4>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Full Stack Web Development Certification</h3>
+ <h3 class="text-right">
+ <a href="https://www.freecodecamp.org/">Free Code Camp</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">October 2016 – August 2017</h4>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Juris Doctor</h3>
+ <h3 class="text-right">
+ <a href="https://www.stjohns.edu/law">
+ St. John's University School of Law
+ </a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">August 2011 – May 2014</h4>
+ <h4 class="gray3 italic">Bar Admission, NY 2015</h4>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>
+ Bachelor of Arts (Philosophy, Italian Studies)
+ </h3>
+ <h3 class="text-right">
+ <a href="https://www.wesleyan.edu/">Wesleyan University</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">August 2005 – May 2009</h4>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Diploma</h3>
+ <h3 class="text-right">
+ <a href="https://www.horacemann.org/">Horace Mann School</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">August 2001 – May 2005</h4>
+{{ end }}
diff --git a/cmd/web/site/templates/static/cv-experience.gohtml b/cmd/web/site/templates/static/cv-experience.gohtml
@@ -0,0 +1,153 @@
+{{ define "cv-experience" }}
+ <h2 class="bg-blue white" id="experience">Experience</h2>
+ <div class="split-row">
+ <h3>
+ Vice-President of Engineering
+ </h3>
+ <h3 class="text-right">
+ <a href="https://www.daybreak.health/">Daybreak Health</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">January 2022 – October 2022</h4>
+ <ul>
+ <li>Architected and built Daybreak Health API, utilized by its cross-platform React Native app.</li>
+ <li>Stood up backend development environment, including build scripts, service configuration, and containerization.</li>
+ <li>Developed database schema, migrations, queries, and Go packages for database models.</li>
+ <li>Integrated backend with AWS, Salesforce, Slack, Twilio, and Health Gorilla APIs.</li>
+ <li>Documented API and backend development setup for onboarding engineers.
+ </li>
+ <li>Mentored and trained junior engineers and interns.</li>
+ <li>Coducted code review and maintained the backend monorepo.</li>
+ </ul>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Vice-President of Engineering</h3>
+ <div>
+ <h3 class="text-right">
+ <a href="https://www.gooduncle.com/">Good Uncle</a>
+ </h3>
+ </div>
+ </div>
+ <h4 class="gray3 italic">January 2018 – January 2022</h4>
+ <h4 class="gray3">
+ Acquired by <a href="https://www.aramark.com">Aramark</a>, May 2019.
+ </h4>
+ <ul>
+ <li>Built backend services, enabling simultaneous cooking and delivery of orders from proprietary vans.</li>
+ <li>~400K orders and $5M in revenue as of August 2021, with an average delivery time of 25 minutes.</li>
+ <li>Rearchitected infrastructure to obtain cost reductions of 99% for AWS, 69% for MongoDB, and 85% for CI/CD.</li>
+ <li>Built control panel web app, utilized by delivery drivers, operations, marketing, and customer support.</li>
+ <li>Managed development and release of cross-platform React Native app; contributed refactors and bug fixes.</li>
+ <li>Consolidated the engineering team to two engineers, reducing the largest engineering expenditure.</li>
+ <li>Built an inventory prediction engine used by the culinary team to estimate the number of meals to prepare.</li>
+ <li>Integrated backend with university payment systems, including: Atrium, CBORD, and Transact.</li>
+ <li>Designed and developed a mealplan subscription system in three months, collecting over $500K in revenue.</li>
+ <li>Created command-line tools to assist the engineering team, including an orders generator to simulate load.</li>
+ <li>Found and fixed mission critical application and infrastructure bugs while under the pressure of live delivery operations.</li>
+ <li>Integrated APIs, including: AWS, Big Query, Google Maps, Intercom, Samsara, Slack, Stripe, and Twilio.</li>
+ </ul>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Consultant</h3>
+ <h3 class="text-right">
+ <a href="https://www.linkedin.com/company/www.chinaenergyfund.org/">
+ China Energy Fund Committee
+ </a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">August 2014 – July 2017</h4>
+ <ul>
+ <li>Drafted speeches delivered at high-level events on public policy and international relations, including the Internet
+ Governance Forum, the UN Secretary-General’s High-level Advisory Group on Sustainable Transport, and CEFC
+ sponsored events on Sino-U.S. relations and sustainable development.
+ </li>
+ <li>Composed editorials for publications on sustainability, international relations, internet governance, and China’s economic
+ development.
+ </li>
+ <li>Assembled the preliminary rules and procedures for the award of a $1M energy grant.</li>
+ <li>As Sherpa, supported Member of the UN Secretary-General’s High-level Advisory Group on Sustainable Transport; contributing to the group’s internal discussions and final outlook report.</li>
+ <li>Supported Member of the Internet Governance Forum’s Multistakeholder Advisory Group, including the Group’s guidance for the 11th Internet Governance Forum.</li>
+ </ul>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Consultant</h3>
+ <h3 class="text-right">
+ FISO Group LLC
+ </h3>
+ </div>
+ <h4 class="gray3 italic">January 2016 – June 2016</h4>
+ <ul>
+ <li>Drafted a private sector Commitment Letter to the Sustainable Development Goals, signed by dozens of businesses.</li>
+ <li>Wrote the concept note, press release, M.C.’s script, and remarks for the UN Assistant Secretary General for Economic Development for the letter’s signing and submission ceremony, held at the United Nations.</li>
+ </ul>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Intern</h3>
+ <h3 class="text-right">
+ <a href="https://www.nyc.gov/site/dep/index.page">
+ New York City Department of Environmental Protection
+ </a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">June 2013 – August 2013</h4>
+ <ul>
+ <li>Researched and drafted legal memoranda in support of NYC Air Code revision, SPDES permit modifications, and compliance with state law and consent orders.</li>
+ <li>Examined legal implications of railbanking proposal, drafted legal memoranda and intergovernmental agreement, and explained railbanking process and provided legal options to Bureau of Water Supply.</li>
+ <li>Represented the City of New York at environmental administrative hearings, wrote and filed appeals to administrative court decisions, conferenced with opposing counsel and pro se respondents.</li>
+ </ul>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Infantry Assault Marine</h3>
+ <h3 class="text-right">
+ <a href="https://www.marines.mil/">United States Marine Corps Reserve</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">August 2009 – Jun 2013</h4>
+ <ul>
+ <li>Trained in the employment of rockets, military explosives, dynamic breaching, infantry and anti-armor operations.</li>
+ <li>Supervised, trained, and mentored junior Marines.</li>
+ <li>Professional achievements: meritorious promotion to Corporal (December 2011), "Excellent" (4.7/5.0) proficiency and conduct marks, Certificate of Commendation (July 2011), completed Scout-Sniper Platoon indoctrination (July 2011), high scorer on Javelin anti-armor missile certification (December 2010), multiple High Physical Fitness Awards.</li>
+ <li>Training achievements: Company Honor Graduate and Platoon Honor Graduate (Recruit Training, November 2009), Meritorious Mast (School of Infantry, April 2010), “Excellent” (4.8/5.0) proficiency and conduct marks. Consistently placed in leadership positions: Platoon Guide (Basic Marine Platoon, Recruit Training), Assault Section Squad Leader (School of Infantry).
+ </li>
+ </ul>
+
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Intern</h3>
+ <h3 class="text-right">
+ <a href="https://www.epa.gov/">
+ United States Environmental Protection Agency
+ </a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">January 2013 – April 2013</h4>
+ <ul>
+ <li>Researched and drafted legal memoranda reviewing the Clean Air Act, EPA manuals and regulations, Environmental Appeals Board decisions, and academic literature.</li>
+ <li>Examined the use of Prevention of Significant Deterioration Best Available Control Technology analysis to promote climate change adaptation.</li>
+ <li>Attended meetings for the development of EPA Region 2 climate change adaptation strategies.</li>
+ </ul>
+
+ <hr class="gray0">
+ <div class="split-row">
+ <h3>Intern</h3>
+ <h3 class="text-right">
+ <a href="https://www.un.org/">United Nations</a>
+ </h3>
+ </div>
+ <h4 class="gray3 italic">September 2012 – Decemeber 2012</h4>
+ <ul>
+ <li>Prepared preliminary draft of <a href="https://digitallibrary.un.org/record/756820?ln=en">Secretary-General’s report on Rio+20 proposal for High Commissioner for Future Generations</a>.</li>
+ <li>Researched academic literature, stakeholder submissions, case law and statutes.</li>
+ <li>Collaborated with NGO directors, political figures, and judges across the globe for research and assessment of the proposal.</li>
+ <li>Reviewed legal and political implications, highlighting support for and vulnerabilities of the proposal.</li>
+ <li>Summarized experiences of national examples of similar offices.</li>
+ <li>Proposed recommendations on possible responses to the proposal, in the context of the existing UN system.</li>
+ </ul>
+
+{{ end }}
diff --git a/cmd/web/site/templates/static/cv-links.gohtml b/cmd/web/site/templates/static/cv-links.gohtml
@@ -0,0 +1,17 @@
+{{ define "cv-links" }}
+ <h2 class="bg-blue white" id="links">Links</h2>
+ <ul class="hlist text-center">
+ <li><a href="/static/resume.pdf">Résumé</a></li>
+ <li>
+ <a href="https://www.linkedin.com/in/dwrz/" target="_blank">LinkedIn</a>
+ </li>
+ <li>
+ <a href="https://github.com/dwrz" target="_blank">Github (Personal)</a>
+ </li>
+ <li>
+ <a href="https://github.com/dwrz-dbhg" target="_blank">
+ Github (Daybreak Health)
+ </a>
+ </li>
+ </ul>
+{{ end }}
diff --git a/cmd/web/site/templates/static/cv-projects.gohtml b/cmd/web/site/templates/static/cv-projects.gohtml
@@ -0,0 +1,55 @@
+{{ define "cv-projects" }}
+ <h2 class="bg-blue white" id="projects">Personal Projects</h2>
+ <ul>
+ <li>
+ <b>
+ <a href="https://github.com/dwrz/src/tree/trunk/cmd/web">
+ dwrz.net
+ </a>
+ </b>
+ — Self-hosted personal website
+ <span class="gray3">(acme-client, relayd, OpenBSD, Go)</span>.
+ </li>
+ <li>
+ <b><a href="https://github.com/dwrz/vigil">vigil</a></b>
+ — Self-hosted security cameras with object detection
+ <span class="gray3">
+ (motion, bash, Raspberry Pi, AWS Rekognition, yolov7)
+ </span>.
+ </li>
+ <li>OpenBSD routers <span class="gray3">(dhcpd, pf, unbound)</span>.</li>
+ <li>FizzBuzz <span class="gray3">(x86 Assembly)</span></li>
+ <li>
+ <b>
+ <a href="https://github.com/dwrz/src/tree/trunk/cmd/minotaur">
+ Minotaur
+ </a>
+ </b>
+ — Terminal game <span class="gray3">(Go)</span>
+ </li>
+ <li>
+ <b>
+ <a href="https://dwrz.net/timeline/2022-10-01/">
+ Minotaur
+ </a>
+ </b>
+ — HTML canvas game game <span class="gray3">(JavaScript)</span>.
+ </li>
+ <li>
+ <b>
+ <a href="https://github.com/dwrz/xv6-riscv/commit/b47a4a40a29b5a29dc36d06c47624b1ec573bce4">
+ Free memory system call in xv6
+ </a>
+ </b>
+ <span class="gray3">(C)</span>
+ </li>
+ <li>
+ <b>
+ <a href="https://github.com/dwrz/src/tree/trunk/cmd/dqs">
+ Diet Quality Score Calculator
+ </a>
+ </b>
+ — Terminal CLI app <span class="gray3">(Go)</span>.
+ </li>
+ </ul>
+{{ end }}
diff --git a/cmd/web/site/templates/static/cv-skills.gohtml b/cmd/web/site/templates/static/cv-skills.gohtml
@@ -0,0 +1,11 @@
+{{ define "cv-skills" }}
+ <h2 class="bg-blue white" id="skills">Skills</h2>
+ <h3>Proficient</h3>
+ <p>
+ AWS, bash, Containers, Go, git, Linux, MongoDB, PostgresSQL, REST APIs, System Design
+ </p>
+ <h3>Experienced</h3>
+ <p>
+ C, CSS3, Emacs Lisp, HTML, OpenBSD, Python, x86 Assembly
+ </p>
+{{ end }}
diff --git a/cmd/web/site/templates/static/cv-summary.gohtml b/cmd/web/site/templates/static/cv-summary.gohtml
@@ -0,0 +1,13 @@
+{{ define "cv-summary" }}
+ <section class="wide64">
+ <blockquote>
+ <p>
+ Whatever your hand finds to do, do it with your might, for there is no
+ work, or thought, or knowledge, or wisdom, in the realm of the dead, to
+ which you are going.
+ </p>
+
+ <cite>Ecclesiastes 9:10</cite>
+ </blockquote>
+ </section>
+{{ end }}
diff --git a/cmd/web/site/templates/static/cv.gohtml b/cmd/web/site/templates/static/cv.gohtml
@@ -0,0 +1,10 @@
+{{ define "cv" }}
+ <article class="wide128">
+ {{ template "cv-summary" }}
+ {{ template "cv-links" }}
+ {{ template "cv-skills" }}
+ {{ template "cv-experience" }}
+ {{ template "cv-education" }}
+ {{ template "cv-projects" }}
+ </article>
+{{ end }}
diff --git a/cmd/web/site/templates/static/entry.gohtml b/cmd/web/site/templates/static/entry.gohtml
@@ -0,0 +1,23 @@
+{{ define "entry" }}
+ <article class="wide64">
+ <h2>{{ .Entry.Title }}</h2>
+ {{ .Entry.Content }}
+ <div class="entry-nav">
+ <div>
+ {{ if .Entry.Previous }}
+ <a href="/timeline/{{ .Entry.Previous.Link }}/">
+ {{ .Entry.Previous.Title }}
+ </a>
+ {{ end }}
+ </div>
+ <div class="text-right">
+ {{ if .Entry.Next }}
+
+ <a href="/timeline/{{ .Entry.Next.Link }}/">
+ {{ .Entry.Next.Title }}
+ </a>
+ {{ end }}
+ </div>
+ </div>
+ </article>
+{{ end }}
diff --git a/cmd/web/site/templates/static/error.gohtml b/cmd/web/site/templates/static/error.gohtml
@@ -0,0 +1,12 @@
+{{ define "error" }}
+ <article>
+ <h3>Request {{ .RequestId }}</h3>
+ <p>{{ .Message }}</p>
+ {{ if .Debug }}
+ <h3>Error</h3>
+ <pre><code>{{ .Text }}</code></pre>
+ <h3>Trace</h3>
+ <pre><code>{{ .Trace }}</code></pre>
+ {{ end }}
+ </article>
+{{ end }}
diff --git a/cmd/web/site/templates/static/footer.gohtml b/cmd/web/site/templates/static/footer.gohtml
@@ -0,0 +1,5 @@
+{{ define "footer" }}
+ <footer>
+ {{ template "nav" . }}
+ </footer>
+{{ end }}
diff --git a/cmd/web/site/templates/static/header.gohtml b/cmd/web/site/templates/static/header.gohtml
@@ -0,0 +1,5 @@
+{{ define "header" }}
+ <header>
+ {{ template "nav" . }}
+ </header>
+{{ end }}
diff --git a/cmd/web/site/templates/static/home.gohtml b/cmd/web/site/templates/static/home.gohtml
@@ -0,0 +1,27 @@
+{{ define "home" }}
+ <article class="wide64">
+ <h1 class="red">David Wen Riccardi-Zhu</h1>
+ <a href="/static/media/1920/dwrz_20200905T205435_edit.jpg">
+ <img alt="Self-portrait while hiking to West Mountain."
+ class="img-center-small img-round"
+ src="/static/media/720/dwrz_20200905T205435_edit.jpg">
+ </a>
+ <h1 class="red">朱为文</h1>
+ <blockquote>
+ <p>"Seven days ago, I said I was going to leave you. It is customary to write a farewell poem, but I am neither poet nor calligrapher. One of you please inscribe my last words."</p>
+ <p>His disciples thought he was joking, but one started to write. Hoshin dictated:</p>
+ <p><em>I came from brilliancy<br>
+ and return to brilliancy.<br>
+ What is this?<br></em></p>
+ <p>The poem was one line short of the customary four, so the disciple said, "Master, we are short by one line."</p>
+ <p>Hoshin, with the roar of a conquering lion, shouted <strong>Kaa!</strong> and was gone.</p>
+ <cite><p>The Last Poem of Hoshin, Zen Flesh, Zen Bones</p></cite>
+ </blockquote>
+
+ <p>I was born in Naples, Italy, my mother's birthplace. My father is from Shanghai, China. In the mid-1990's we moved to Roosevelt Island, in New York City. I attended the United Nations International School, La Scuola d'Italia Guglielmo Marconi, and the Horace Mann School. I went to Wesleyan University for college, where I majored in Philosophy and Italian Studies. After Wesleyan I enlisted in the Marine Corps Reserve, where I served for four years as an Infantry Assault Marine. I studied law at Saint John's University, and was admitted to the New York Bar in 2015. My background is primarily in environmental law and policy. After working in that field for a few years, I switched tracks again; these days I am employed as a software engineer.</p>
+
+ <p>I love nature, time with family, and learning, thinking and living mindfully. I am happiest when I am outdoors, pondering existence, reading books, or being physically active in some way. I don't need much, beyond the basics, to make me happy, and I know I have much to be grateful for. Most of my frustration stems from the excesses of modernity, the spiritual sickness that seems to pervade my species, injustice, and shortsightedness. I worry more about trends than incidences, and care more for the mean than the outliers.</p>
+
+ <p>I believe that life is worth living, that the world is worth fighting for, in human potential, and in the search for knowledge and enlightenment. Since I know I will only be on Earth for so long, at least in this form, these are the causes I hope to advance in my allotted time.</p>
+ </article>
+{{ end }}}}
diff --git a/cmd/web/site/templates/static/main.gohtml b/cmd/web/site/templates/static/main.gohtml
@@ -0,0 +1,17 @@
+{{ define "main" }}
+ <main>
+ {{ if eq .View "contact" }}
+ {{ template "contact" . }}
+ {{ else if eq .View "cv" }}
+ {{ template "cv" . }}
+ {{ else if eq .View "entry" }}
+ {{ template "entry" . }}
+ {{ else if eq .View "error" }}
+ {{ template "error" . }}
+ {{ else if eq .View "home" }}
+ {{ template "home" . }}
+ {{ else if eq .View "timeline" }}
+ {{ template "timeline" . }}
+ {{ end }}
+ </main>
+{{ end }}
diff --git a/cmd/web/site/templates/static/nav.gohtml b/cmd/web/site/templates/static/nav.gohtml
@@ -0,0 +1,26 @@
+{{ define "nav" }}
+ {{ $view := .View }}
+ <nav class="nav">
+ {{ if eq $view "home" }}
+ | <span>home</span> |
+ {{ else }}
+ | <a href="/">home</a> |
+ {{ end }}
+ {{ if eq $view "timeline" }}
+ <span>timeline</span> |
+ {{ else }}
+ <a href="/timeline/">timeline</a> |
+ {{ end }}
+ <a href="https://code.dwrz.net/src/">code</a> |
+ {{ if eq $view "cv" }}
+ <span>cv</span> |
+ {{ else }}
+ <a href="/cv/">cv</a> |
+ {{ end }}
+ {{ if eq $view "contact" }}
+ <span>contact</span> |
+ {{ else }}
+ <a href="/contact/">contact</a> |
+ {{ end }}
+ </nav>
+{{ end }}
diff --git a/cmd/web/site/templates/static/timeline.gohtml b/cmd/web/site/templates/static/timeline.gohtml
@@ -0,0 +1,39 @@
+{{ define "timeline" }}
+ <article class="wide128">
+ {{ range .Years }}
+ <h2 class="bg-green white">{{ .Text }}</h2>
+ {{ range .Entries }}
+ {{ if .Cover }}
+ <div class="timeline-row">
+ <div>
+ <a href="/timeline/{{ .Link }}/">
+ <img alt="" src="/static/media/480/{{ .Cover }}">
+ </a>
+ </div>
+ <div>
+ <a class="text-large" href="/timeline/{{ .Link }}/">
+ {{ .Title }}
+ </a>
+ <br>
+ <span class="text-small gray3">
+ {{ .Date.Format "2006-01-02" }}
+ </span>
+ </div>
+ </div>
+ {{ else }}
+ <div class="timeline-row-single">
+ <div>
+ <a class="text-large" href="/timeline/{{ .Link }}/">
+ {{ .Title }}
+ </a>
+ <br>
+ <span class="text-small gray3">
+ {{ .Date.Format "2006-01-02" }}
+ </span>
+ </div>
+ </div>
+ {{ end }}
+ {{ end }}
+ {{ end }}
+ </article>
+{{ end }}
diff --git a/cmd/web/site/templates/templates.go b/cmd/web/site/templates/templates.go
@@ -0,0 +1,45 @@
+package templates
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "strings"
+)
+
+//go:embed static/*
+var static embed.FS
+
+const fileType = ".gohtml"
+
+var funcs = template.FuncMap{}
+
+func Parse() (*template.Template, error) {
+ var tmpl = template.New("").Funcs(funcs)
+
+ parseFS := func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+
+ if strings.Contains(path, fileType) {
+ if _, err := tmpl.ParseFS(static, path); err != nil {
+ return fmt.Errorf(
+ "failed to parse template: %v", err,
+ )
+ }
+ }
+
+ return nil
+ }
+
+ if err := fs.WalkDir(static, ".", parseFS); err != nil {
+ return nil, fmt.Errorf("failed to parse templates: %v", err)
+ }
+
+ return tmpl, nil
+}