diff options
57 files changed, 3459 insertions, 311 deletions
diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index a959665..2932e47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,31 @@ -/public/ +# Allowlisting gitignore template for GO projects prevents us +# from adding various unwanted local files, such as generated +# files, developer configurations or IDE-specific files etc. +# +# Recommended: Go.AllowList.gitignore -# Local Netlify folder -.netlify \ No newline at end of file +# Ignore everything +* + +# But not these files... +!.gitignore + +!*.go +!go.sum +!go.mod + +!README.md +!LICENSE + +!.envrc +!justfile +!*.nix +!*.toml +!/flake.lock + +!/content/**/*.md +!/static/**/* +!/templates/* + +# ...even if they are in subdirectories +!*/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 19a279c..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -variables: - # This variable will ensure that the CI runner pulls in your theme from the submodule - GIT_SUBMODULE_STRATEGY: recursive - -image: nixery.dev/shell/gnugrep/git/zola - -test: - script: - - zola - except: - variables: - - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - -pages: - script: - - zola build - artifacts: - paths: - - public - only: - variables: - - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edbe912 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Alan Pearce <alan@alanpearce.eu> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc27bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# homestead + +## Goals + +1. Static web server with prometheus-based analytics +2. Dynamic web server capable of generating Zola-based websites +3. More indieweb features + diff --git a/_redirects b/_redirects deleted file mode 100644 index 2ee3842..0000000 --- a/_redirects +++ /dev/null @@ -1 +0,0 @@ -/post/index.xml /atom.xml 301 \ No newline at end of file diff --git a/cmd/build/build.go b/cmd/build/build.go new file mode 100644 index 0000000..5daa940 --- /dev/null +++ b/cmd/build/build.go @@ -0,0 +1,196 @@ +package main + +import ( + "fmt" + "io" + "log" + "log/slog" + "net/url" + "os" + "path" + "slices" + + "website/internal/config" + + cp "github.com/otiai10/copy" + "github.com/pkg/errors" +) + +func mkdirp(dirs ...string) error { + return os.MkdirAll(path.Join(dirs...), 0755) +} + +func outputToFile(output io.Reader, filename ...string) error { + file, err := os.OpenFile(path.Join(filename...), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return errors.WithMessage(err, "could not open output file") + } + defer file.Close() + + if _, err := file.ReadFrom(output); err != nil { + return errors.WithMessage(err, "could not write output file") + } + return nil +} + +func build(outDir string, config config.Config) error { + privateDir := path.Join(outDir, "private") + if err := mkdirp(privateDir); err != nil { + return errors.WithMessage(err, "could not create private directory") + } + publicDir := path.Join(outDir, "public") + if err := mkdirp(publicDir); err != nil { + return errors.WithMessage(err, "could not create public directory") + } + + err := cp.Copy("static", publicDir, cp.Options{ + PreserveTimes: true, + PermissionControl: cp.AddPermission(0755), + }) + if err != nil { + log.Panic(errors.Errorf("could not copy static files: %v", err)) + } + + if err := mkdirp(publicDir, "post"); err != nil { + return errors.WithMessage(err, "could not create post output directory") + } + slog.Debug("reading posts") + posts, tags, err := readPosts("content", "post", publicDir) + if err != nil { + return err + } + + for _, post := range posts { + if err := mkdirp(publicDir, "post", post.Basename); err != nil { + return errors.WithMessage(err, "could not create directory for post") + } + slog.Debug("rendering post", "post", post.Basename) + output, err := renderPost(post, config) + if err != nil { + return errors.WithMessagef(err, "could not render post %s", post.Input) + } + if err := outputToFile(output, post.Output); err != nil { + return err + } + } + + if err := mkdirp(publicDir, "tags"); err != nil { + return errors.WithMessage(err, "could not create directory for tags") + } + slog.Debug("rendering tags list") + output, err := renderTags(tags, config, "/tags") + if err != nil { + return errors.WithMessage(err, "could not render tags") + } + if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil { + return err + } + + for _, tag := range tags.ToSlice() { + matchingPosts := []Post{} + for _, post := range posts { + if slices.Contains(post.Taxonomies.Tags, tag) { + matchingPosts = append(matchingPosts, post) + } + } + if err := mkdirp(publicDir, "tags", tag); err != nil { + return errors.WithMessage(err, "could not create directory") + } + slog.Debug("rendering tags page", "tag", tag) + output, err := renderListPage(tag, config, matchingPosts, "/tags/"+tag) + if err != nil { + return errors.WithMessage(err, "could not render tag page") + } + if err := outputToFile(output, publicDir, "tags", tag, "index.html"); err != nil { + return err + } + + slog.Debug("rendering tags feed", "tag", tag) + output, err = renderFeed(fmt.Sprintf("%s - %s", config.Title, tag), config, matchingPosts, tag) + if err != nil { + return errors.WithMessage(err, "could not render tag feed page") + } + if err := outputToFile(output, publicDir, "tags", tag, "atom.xml"); err != nil { + return err + } + } + + slog.Debug("rendering list page") + listPage, err := renderListPage("", config, posts, "/post") + if err != nil { + return errors.WithMessage(err, "could not render list page") + } + if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil { + return err + } + + slog.Debug("rendering feed") + feed, err := renderFeed(config.Title, config, posts, "feed") + if err != nil { + return errors.WithMessage(err, "could not render feed") + } + if err := outputToFile(feed, publicDir, "atom.xml"); err != nil { + return err + } + + slog.Debug("rendering feed styles") + feedStyles, err := renderFeedStyles() + if err != nil { + return errors.WithMessage(err, "could not render feed styles") + } + if err := outputToFile(feedStyles, publicDir, "feed-styles.xsl"); err != nil { + return err + } + + slog.Debug("rendering homepage") + homePage, err := renderHomepage(config, posts, "/") + if err != nil { + return errors.WithMessage(err, "could not render homepage") + } + if err := outputToFile(homePage, publicDir, "index.html"); err != nil { + return err + } + + slog.Debug("rendering 404 page") + notFound, err := render404(config, "/404.html") + if err != nil { + return errors.WithMessage(err, "could not render 404 page") + } + if err := outputToFile(notFound, privateDir, "404.html"); err != nil { + return err + } + + return nil +} + +type IOConfig struct { + Source string `conf:"default:.,short:s"` + Destination string `conf:"default:website,short:d"` + BaseURL config.URL +} + +func buildSite(ioConfig IOConfig) error { + config, err := config.GetConfig() + if err != nil { + log.Panic(errors.Errorf("could not get config: %v", err)) + } + + if ioConfig.BaseURL.URL != nil { + config.BaseURL.URL, err = url.Parse(ioConfig.BaseURL.String()) + if err != nil { + log.Panic(errors.Errorf("highly unlikely: %v", err)) + } + } + + err = os.RemoveAll(ioConfig.Destination) + if err != nil { + log.Panic(errors.Errorf("could not remove public directory: %v", err)) + } + + if err := build(ioConfig.Destination, *config); err != nil { + return err + } + + slog.Debug("done") + return nil +} diff --git a/cmd/build/main.go b/cmd/build/main.go new file mode 100644 index 0000000..9b6a79b --- /dev/null +++ b/cmd/build/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "io/fs" + "log" + "log/slog" + "os" + + "github.com/BurntSushi/toml" + "github.com/ardanlabs/conf/v3" + "github.com/pkg/errors" +) + +func main() { + if os.Getenv("DEBUG") != "" { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + slog.Debug("starting build process") + + ioConfig := IOConfig{} + if help, err := conf.Parse("", &ioConfig); err != nil { + if errors.Is(err, conf.ErrHelpWanted) { + fmt.Println(help) + os.Exit(1) + } + log.Panicf("parsing I/O configuration: %v", err) + } + + if ioConfig.Source != "." { + err := os.Chdir(ioConfig.Source) + if err != nil { + log.Panic("could not change to source directory") + } + } + + if err := buildSite(ioConfig); err != nil { + switch cause := errors.Cause(err).(type) { + case *fs.PathError: + slog.Info("pathError") + slog.Error(fmt.Sprintf("%s", err)) + case toml.ParseError: + slog.Info("parseError") + slog.Error(fmt.Sprintf("%s", err)) + default: + slog.Info("other") + slog.Error(fmt.Sprintf("cause:%+v", errors.Cause(cause))) + slog.Error(fmt.Sprintf("%+v", cause)) + } + os.Exit(1) + } +} diff --git a/cmd/build/posts.go b/cmd/build/posts.go new file mode 100644 index 0000000..f03caf3 --- /dev/null +++ b/cmd/build/posts.go @@ -0,0 +1,121 @@ +package main + +import ( + "bytes" + "log/slog" + "os" + "path" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/adrg/frontmatter" + mapset "github.com/deckarep/golang-set/v2" + "github.com/pkg/errors" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + htmlrenderer "github.com/yuin/goldmark/renderer/html" +) + +type PostMatter struct { + Date time.Time `toml:"date"` + Description string `toml:"description"` + Title string `toml:"title"` + Taxonomies struct { + Tags []string `toml:"tags"` + } `toml:"taxonomies"` +} + +type Post struct { + Input string + Output string + Basename string + URL string + Content string + PostMatter +} + +type Tags mapset.Set[string] + +var markdown = goldmark.New( + goldmark.WithRendererOptions( + htmlrenderer.WithUnsafe(), + ), + goldmark.WithExtensions( + extension.GFM, + extension.Footnote, + extension.Typographer, + ), +) + +func getPost(filename string) (*PostMatter, []byte, error) { + matter := PostMatter{} + content, err := os.Open(filename) + defer content.Close() + if err != nil { + return nil, nil, errors.WithMessagef(err, "could not open post %s", filename) + } + rest, err := frontmatter.MustParse(content, &matter) + if err != nil { + return nil, nil, errors.WithMessagef(err, "could not parse front matter of post %s", filename) + } + + return &matter, rest, nil +} + +func renderMarkdown(content []byte) (string, error) { + var buf bytes.Buffer + if err := markdown.Convert(content, &buf); err != nil { + return "", errors.WithMessage(err, "could not convert markdown content") + } + return buf.String(), nil +} + +func readPosts(root string, inputDir string, outputDir string) ([]Post, Tags, error) { + tags := mapset.NewSet[string]() + posts := []Post{} + subdir := filepath.Join(root, inputDir) + files, err := os.ReadDir(subdir) + if err != nil { + return nil, nil, errors.WithMessagef(err, "could not read post directory %s", subdir) + } + outputReplacer := strings.NewReplacer(root, outputDir, ".md", "/index.html") + urlReplacer := strings.NewReplacer(root, "", ".md", "/") + for _, f := range files { + pathFromRoot := filepath.Join(subdir, f.Name()) + if !f.IsDir() && path.Ext(pathFromRoot) == ".md" { + output := outputReplacer.Replace(pathFromRoot) + url := urlReplacer.Replace(pathFromRoot) + slog.Debug("reading post", "post", pathFromRoot) + matter, content, err := getPost(pathFromRoot) + if err != nil { + return nil, nil, err + } + + for _, tag := range matter.Taxonomies.Tags { + tags.Add(strings.ToLower(tag)) + } + + slog.Debug("rendering markdown in post", "post", pathFromRoot) + html, err := renderMarkdown(content) + if err != nil { + return nil, nil, err + } + post := Post{ + Input: pathFromRoot, + Output: output, + Basename: filepath.Base(url), + URL: url, + PostMatter: *matter, + Content: html, + } + + posts = append(posts, post) + } + } + slices.SortFunc(posts, func(a, b Post) int { + return b.Date.Compare(a.Date) + }) + return posts, tags, nil +} diff --git a/cmd/build/template.go b/cmd/build/template.go new file mode 100644 index 0000000..84cf57c --- /dev/null +++ b/cmd/build/template.go @@ -0,0 +1,359 @@ +package main + +import ( + "encoding/xml" + "fmt" + "io" + "log/slog" + "net/url" + "os" + "strings" + "sync" + "time" + "website/internal/atom" + "website/internal/config" + + "github.com/PuerkitoBio/goquery" + "github.com/a-h/htmlformat" + "github.com/antchfx/xmlquery" + "github.com/antchfx/xpath" + mapset "github.com/deckarep/golang-set/v2" + "golang.org/x/net/html" +) + +var ( + assetsOnce sync.Once + css string + countHTML *goquery.Document + templates map[string]*os.File = make(map[string]*os.File) +) + +func loadTemplate(path string) (file *os.File, err error) { + if templates[path] == nil { + file, err = os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + templates[path] = file + } + file = templates[path] + return +} + +var ( + imgOnce sync.Once + img *goquery.Selection + urlTemplate *url.URL +) + +type QuerySelection struct { + *goquery.Selection +} + +type QueryDocument struct { + *goquery.Document +} + +func NewDocumentFromReader(r io.Reader) (*QueryDocument, error) { + doc, err := goquery.NewDocumentFromReader(r) + return &QueryDocument{doc}, err +} + +func (q *QueryDocument) Find(selector string) *QuerySelection { + return &QuerySelection{q.Document.Find(selector)} +} + +func NewDocumentNoScript(r io.Reader) (*goquery.Document, error) { + root, err := html.ParseWithOptions(r, html.ParseOptionEnableScripting(false)) + return goquery.NewDocumentFromNode(root), err +} + +func (root QuerySelection) setImgURL(pageURL string, pageTitle string) QuerySelection { + clone := countHTML.Clone() + imgOnce.Do(func() { + var err error + img = clone.Find("img") + attr, _ := img.Attr("src") + if attr == "" { + panic("<img> does not have src attribute") + } + urlTemplate, err = url.Parse(attr) + if err != nil { + panic(err.Error()) + } + }) + q := urlTemplate.Query() + urlTemplate.RawQuery = "" + q.Set("p", pageURL) + q.Set("t", pageTitle) + output := urlTemplate.String() + "?" + q.Encode() + clone.Find("img").SetAttr("src", output) + root.AppendSelection(clone.Find("body").Children()) + return root +} + +func layout(filename string, config config.Config, pageTitle string, pageURL string) (*goquery.Document, error) { + html, err := loadTemplate(filename) + if err != nil { + return nil, err + } + defer html.Seek(0, io.SeekStart) + assetsOnce.Do(func() { + var bytes []byte + bytes, err = os.ReadFile("templates/style.css") + if err != nil { + return + } + css = string(bytes) + countFile, err := os.OpenFile("templates/count.html", os.O_RDONLY, 0) + if err != nil { + return + } + defer countFile.Close() + countHTML, err = NewDocumentNoScript(countFile) + }) + if err != nil { + return nil, err + } + + doc, err := NewDocumentFromReader(html) + if err != nil { + return nil, err + } + doc.Find("html").SetAttr("lang", config.DefaultLanguage) + doc.Find("head > link[rel=alternate]").SetAttr("title", config.Title) + doc.Find("head > link[rel=canonical]").SetAttr("href", pageURL) + doc.Find(".title").SetText(config.Title) + doc.Find("title").Add(".p-name").SetText(pageTitle) + doc.Find("head > style").SetHtml(css) + doc.Find("body").setImgURL(pageURL, pageTitle) + nav := doc.Find("nav") + navLink := doc.Find("nav a") + nav.Empty() + for _, link := range config.Menus["main"] { + nav.AppendSelection(navLink.Clone().SetAttr("href", link.URL).SetText(link.Name)) + } + return doc.Document, nil +} + +func renderPost(post Post, config config.Config) (r io.Reader, err error) { + doc, err := layout("templates/post.html", config, post.PostMatter.Title, post.URL) + if err != nil { + return nil, err + } + doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author") + doc.Find(".h-entry .dt-published"). + SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). + SetText( + post.PostMatter.Date.Format("2006-01-02"), + ) + doc.Find(".h-entry .e-content").SetHtml(post.Content) + categories := doc.Find(".h-entry .p-categories") + tpl := categories.Find(".p-category").ParentsUntilSelection(categories) + tpl.Remove() + for _, tag := range post.Taxonomies.Tags { + cat := tpl.Clone() + cat.Find(".p-category").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag) + categories.AppendSelection(cat) + } + + return renderHTML(doc), nil +} + +func renderTags(tags Tags, config config.Config, url string) (io.Reader, error) { + doc, err := layout("templates/tags.html", config, config.Title, url) + if err != nil { + return nil, err + } + tagList := doc.Find(".tags") + tpl := doc.Find(".h-feed") + tpl.Remove() + for _, tag := range mapset.Sorted(tags) { + li := tpl.Clone() + li.Find("a").SetAttr("href", fmt.Sprintf("/tags/%s/", tag)).SetText("#" + tag) + tagList.AppendSelection(li) + } + return renderHTML(doc), nil +} + +func renderListPage(tag string, config config.Config, posts []Post, url string) (io.Reader, error) { + var title string + if len(tag) > 0 { + title = tag + } else { + title = config.Title + } + doc, err := layout("templates/list.html", config, title, url) + if err != nil { + return nil, err + } + feed := doc.Find(".h-feed") + tpl := feed.Find(".h-entry") + tpl.Remove() + + doc.Find(".title").AddClass("p-author h-card").SetAttr("rel", "author") + if tag == "" { + doc.Find(".filter").Remove() + } else { + doc.Find(".filter").Find("h3").SetText("#" + tag) + } + + for _, post := range posts { + entry := tpl.Clone() + entry.Find(".p-name").SetText(post.Title).SetAttr("href", post.URL) + entry.Find(".dt-published"). + SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). + SetText(post.PostMatter.Date.Format("2006-01-02")) + feed.AppendSelection(entry) + } + + return renderHTML(doc), nil +} + +func renderHomepage(config config.Config, posts []Post, url string) (io.Reader, error) { + _, index, err := getPost("content/_index.md") + if err != nil { + return nil, err + } + doc, err := layout("templates/homepage.html", config, config.Title, url) + if err != nil { + return nil, err + } + doc.Find("body").AddClass("h-card") + doc.Find(".title").AddClass("p-name u-url") + + html, err := renderMarkdown(index) + doc.Find("#content").SetHtml(html) + + feed := doc.Find(".h-feed") + tpl := feed.Find(".h-entry") + tpl.Remove() + + for _, post := range posts[0:3] { + entry := tpl.Clone() + entry.Find(".p-name").SetText(post.Title) + entry.Find(".u-url").SetAttr("href", post.URL) + entry. + Find(".dt-published"). + SetAttr("datetime", post.PostMatter.Date.UTC().Format(time.RFC3339)). + SetText(post.PostMatter.Date.Format("2006-01-02")) + + feed.AppendSelection(entry) + } + doc.Find(".u-email"). + SetAttr("href", fmt.Sprintf("mailto:%s", config.Email)). + SetText(config.Email) + + elsewhere := doc.Find(".elsewhere") + linkRelMe := elsewhere.Find(".u-url[rel=me]").ParentsUntil("ul") + linkRelMe.Remove() + + for _, link := range config.Menus["me"] { + el := linkRelMe.Clone() + el.Find("a").SetAttr("href", link.URL).SetText(link.Name) + elsewhere.AppendSelection(el) + } + + return renderHTML(doc), nil +} + +func render404(config config.Config, url string) (io.Reader, error) { + doc, err := layout("templates/404.html", config, "404 Not Found", url) + if err != nil { + return nil, err + } + return renderHTML(doc), nil +} + +func renderFeed(title string, config config.Config, posts []Post, specific string) (io.Reader, error) { + reader, err := loadTemplate("templates/feed.xml") + if err != nil { + return nil, err + } + defer reader.Seek(0, io.SeekStart) + doc, err := xmlquery.Parse(reader) + feed := doc.SelectElement("feed") + feed.SelectElement("title").FirstChild.Data = title + feed.SelectElement("link").SetAttr("href", config.BaseURL.String()) + feed.SelectElement("id").FirstChild.Data = atom.MakeTagURI(config, specific) + datetime, err := posts[0].Date.UTC().MarshalText() + feed.SelectElement("updated").FirstChild.Data = string(datetime) + tpl := feed.SelectElement("entry") + xmlquery.RemoveFromTree(tpl) + + for _, post := range posts { + fullURL, err := url.JoinPath(config.BaseURL.String(), post.URL) + if err != nil { + return nil, err + } + text, err := xml.MarshalIndent(&atom.FeedEntry{ + Title: post.Title, + Link: atom.MakeLink(fullURL), + Id: atom.MakeTagURI(config, post.Basename), + Updated: post.Date.UTC(), + Summary: post.Description, + Author: config.Title, + Content: atom.FeedContent{ + Content: post.Content, + Type: "html", + }, + }, " ", " ") + if err != nil { + return nil, err + } + entry, err := xmlquery.ParseWithOptions(strings.NewReader(string(text)), xmlquery.ParserOptions{ + Decoder: &xmlquery.DecoderOptions{ + Strict: false, + AutoClose: xml.HTMLAutoClose, + Entity: xml.HTMLEntity, + }, + }) + if err != nil { + return nil, err + } + xmlquery.AddChild(feed, entry.SelectElement("entry")) + } + + return strings.NewReader(doc.OutputXML(true)), nil +} + +func renderFeedStyles() (io.Reader, error) { + reader, err := loadTemplate("templates/feed-styles.xsl") + if err != nil { + return nil, err + } + defer reader.Seek(0, io.SeekStart) + nsMap := map[string]string{ + "xsl": "http://www.w3.org/1999/XSL/Transform", + "atom": "http://www.w3.org/2005/Atom", + "xhtml": "http://www.w3.org/1999/xhtml", + } + doc, err := xmlquery.Parse(reader) + expr, err := xpath.CompileWithNS("//xhtml:style", nsMap) + if err != nil { + return nil, err + } + style := xmlquery.QuerySelector(doc, expr) + xmlquery.AddChild(style, &xmlquery.Node{ + Type: xmlquery.TextNode, + Data: string(css), + }) + return strings.NewReader(doc.OutputXML(true)), nil +} + +func renderHTML(doc *goquery.Document) io.Reader { + r, w := io.Pipe() + + // TODO: return errors to main thread + go func() { + w.Write([]byte("<!doctype html>\n")) + err := htmlformat.Nodes(w, []*html.Node{doc.Children().Get(0)}) + if err != nil { + slog.Error("error rendering html", "error", err) + w.CloseWithError(err) + return + } + defer w.Close() + }() + return r +} diff --git a/cmd/server/filemap.go b/cmd/server/filemap.go new file mode 100644 index 0000000..5f7e1bb --- /dev/null +++ b/cmd/server/filemap.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "hash/fnv" + "io" + "io/fs" + "log" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" +) + +type File struct { + filename string + etag string +} + +var files = map[string]File{} + +func hashFile(filename string) (string, error) { + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + hash := fnv.New64a() + if _, err := io.Copy(hash, f); err != nil { + return "", err + } + return fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), nil +} + +func registerFile(urlpath string, filepath string) error { + if files[urlpath] != (File{}) { + log.Printf("registerFile called with duplicate file, urlPath: %s", urlpath) + return nil + } + hash, err := hashFile(filepath) + if err != nil { + return err + } + files[urlpath] = File{ + filename: filepath, + etag: hash, + } + return nil +} + +func registerContentFiles(root string) error { + err := filepath.WalkDir(root, func(filePath string, f fs.DirEntry, err error) error { + if err != nil { + return errors.WithMessagef(err, "failed to access path %s", filePath) + } + relPath, err := filepath.Rel(root, filePath) + if err != nil { + return errors.WithMessagef(err, "failed to make path relative, path: %s", filePath) + } + urlPath, _ := strings.CutSuffix(relPath, "index.html") + if !f.IsDir() { + slog.Debug("registering file", "urlpath", "/"+urlPath) + return registerFile("/"+urlPath, filePath) + } + return nil + }) + if err != nil { + return err + } + return nil +} + +func GetFile(urlPath string) File { + return files[urlPath] +} diff --git a/cmd/server/logging.go b/cmd/server/logging.go new file mode 100644 index 0000000..601baab --- /dev/null +++ b/cmd/server/logging.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "io" + "net/http" +) + +type loggingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + // avoids warning: superfluous response.WriteHeader call + if lrw.statusCode != http.StatusOK { + lrw.ResponseWriter.WriteHeader(code) + } +} + +func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{w, http.StatusOK} +} + +type wrappedHandlerOptions struct { + defaultHostname string + logger io.Writer +} + +func wrapHandlerWithLogging(wrappedHandler http.Handler, opts wrappedHandlerOptions) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + scheme = "http" + } + host := r.Header.Get("Host") + if host == "" { + host = opts.defaultHostname + } + lw := NewLoggingResponseWriter(w) + wrappedHandler.ServeHTTP(lw, r) + statusCode := lw.statusCode + fmt.Fprintf( + opts.logger, + "%s %s %d %s %s %s\n", + scheme, + r.Method, + statusCode, + host, + r.URL.Path, + lw.Header().Get("Location"), + ) + }) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..b6817d8 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "log" + "log/slog" + "os" + cfg "website/internal/config" + + "github.com/ardanlabs/conf/v3" + "github.com/pkg/errors" +) + +var ( + CommitSHA string + ShortSHA string +) + +type Config struct { + Production bool `conf:"default:false"` + ListenAddress string `conf:"default:localhost"` + Port uint16 `conf:"default:3000,short:p"` + BaseURL cfg.URL `conf:"default:http://localhost:3000,short:b"` + RedirectOtherHostnames bool `conf:"default:false"` +} + +func main() { + if os.Getenv("DEBUG") != "" { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + + runtimeConfig := Config{} + help, err := conf.Parse("", &runtimeConfig) + if err != nil { + if errors.Is(err, conf.ErrHelpWanted) { + fmt.Println(help) + os.Exit(1) + } + log.Panicf("parsing runtime configuration: %v", err) + } + + startServer(&runtimeConfig) +} diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..9a1e48a --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,161 @@ +package main + +import ( + "fmt" + "io" + "log" + "log/slog" + "mime" + "net" + "net/http" + "os" + "strings" + "time" + + cfg "website/internal/config" + + "github.com/getsentry/sentry-go" + sentryhttp "github.com/getsentry/sentry-go/http" + "github.com/shengyanli1982/law" +) + +var config *cfg.Config + +type HTTPError struct { + Error error + Message string + Code int +} + +func canonicalisePath(path string) (cPath string, differs bool) { + cPath = path + if strings.HasSuffix(path, "/index.html") { + cPath, differs = strings.CutSuffix(path, "index.html") + } else if !strings.HasSuffix(path, "/") && files[path+"/"] != (File{}) { + cPath, differs = path+"/", true + } + return cPath, differs +} + +func serveFile(w http.ResponseWriter, r *http.Request) *HTTPError { + urlPath, shouldRedirect := canonicalisePath(r.URL.Path) + if shouldRedirect { + http.Redirect(w, r, urlPath, 302) + return nil + } + file := GetFile(urlPath) + if file == (File{}) { + return &HTTPError{ + Message: "File not found", + Code: http.StatusNotFound, + } + } + w.Header().Add("ETag", file.etag) + w.Header().Add("Vary", "Accept-Encoding") + for k, v := range config.Extra.Headers { + w.Header().Add(k, v) + } + + http.ServeFile(w, r, files[urlPath].filename) + return nil +} + +type webHandler func(http.ResponseWriter, *http.Request) *HTTPError + +func (fn webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer func() { + if fail := recover(); fail != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("runtime panic!", "error", fail) + } + }() + w.Header().Set("Server", fmt.Sprintf("website (%s)", ShortSHA)) + if err := fn(w, r); err != nil { + if strings.Contains(r.Header.Get("Accept"), "text/html") { + w.WriteHeader(err.Code) + notFoundPage := "website/private/404.html" + http.ServeFile(w, r, notFoundPage) + } else { + http.Error(w, err.Message, err.Code) + } + } +} + +var newMIMEs = map[string]string{ + ".xsl": "text/xsl", +} + +func fixupMIMETypes() { + for ext, newType := range newMIMEs { + if err := mime.AddExtensionType(ext, newType); err != nil { + slog.Error("could not update mime type", "ext", ext, "mime", newType) + } + } +} + +func startServer(runtimeConfig *Config) { + fixupMIMETypes() + + c, err := cfg.GetConfig() + if err != nil { + log.Panicf("parsing configuration file: %v", err) + } + config = c + + prefix := "website/public" + slog.Debug("registering content files", "prefix", prefix) + err = registerContentFiles(prefix) + if err != nil { + log.Panicf("registering content files: %v", err) + } + + env := "development" + if runtimeConfig.Production { + env = "production" + } + err = sentry.Init(sentry.ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + Dsn: os.Getenv("SENTRY_DSN"), + Release: CommitSHA, + Environment: env, + }) + if err != nil { + log.Panic("could not set up sentry") + } + defer sentry.Flush(2 * time.Second) + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, + }) + + mux := http.NewServeMux() + slog.Debug("binding main handler to", "host", runtimeConfig.BaseURL.Hostname()+"/") + mux.Handle(runtimeConfig.BaseURL.Hostname()+"/", webHandler(serveFile)) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + newURL := runtimeConfig.BaseURL.String() + r.URL.String() + http.Redirect(w, r, newURL, 301) + }) + + var logWriter io.Writer + if runtimeConfig.Production { + logWriter = law.NewWriteAsyncer(os.Stdout, nil) + } else { + logWriter = os.Stdout + } + http.Handle("/", + sentryHandler.Handle( + wrapHandlerWithLogging(mux, wrappedHandlerOptions{ + defaultHostname: runtimeConfig.BaseURL.Hostname(), + logger: logWriter, + }), + ), + ) + // no logging, no sentry + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, fmt.Sprint(runtimeConfig.Port)) + log.Fatal(http.ListenAndServe(listenAddress, nil)) +} diff --git a/config.toml b/config.toml index a7ad8d8..603c8d3 100644 --- a/config.toml +++ b/config.toml @@ -1,48 +1,54 @@ default_language = "en-GB" -base_url = "https://www.alanpearce.eu" +base_url = "https://alanpearce.eu" +redirect_other_hostnames = true title = "Alan Pearce" +email = "alan@alanpearce.eu" description = "Developer, Emacs User" -generate_feed = true - -highlight_code = true -highlight_theme = "ascetic-white" - -theme = "xmin" +domain_start_date = "2014-06-07" +original_domain = "alanpearce.eu" [[taxonomies]] name = "tags" feed = true -[extra] -footer = "Licensed under a <a rel=\"license\" href=\"http://creativecommons.org/licenses/by/4.0/\">Creative Commons Attribution 4.0 International License</a>." -gpg_fingerprint = "48E6 576C 0707 388C B8BE FD0C CD4B EB92 A8D4 6583" -gpg_url = "/public_key.asc" -author_name = "Alan Pearce" -author_image = "/img/me-thumb.jpg" +[extra.headers] +cache-control = "max-age=14400" +x-content-type-options = "nosniff" +content-security-policy = "default-src 'none'; img-src 'self' https://gc.zgo.at; script-src 'self' https://gc.zgo.at; style-src 'unsafe-inline'; frame-ancestors https://kagi.com; connect-src https://alanpearce-eu.goatcounter.com/count; require-trusted-types-for 'script'" -[[extra.menu.main]] +[[menus.main]] name = "Home" url = "/" - weight = 1 -[[extra.menu.main]] +[[menus.main]] name = "Posts" url = "/post/" -[[extra.menu.main]] +[[menus.main]] + name = "Feed" + url = "/atom.xml" +[[menus.main]] name = "Tags" url = "/tags/" -[[extra.menu.main]] +[[menus.main]] name = "Repositories" url = "https://git.alanpearce.eu" -[[extra.menu.contact]] - name = "alan@alanpearce.eu" - url = "mailto:alan@alanpearce.eu" - weight = 1 -[[extra.menu.contact]] - name = "GitLab" - url = "https://gitlab.com/alanpearce" -[[extra.menu.contact]] - name = "GitHub" - url = "https://github.com/alanpearce" +[[menus.me]] + name = "Codeberg" + url = "https://codeberg.org/alanpearce" +[[menus.me]] + name = "GitLab" + url = "https://gitlab.com/alanpearce/" +[[menus.me]] + name = "GitHub" + url = "https://github.com/alanpearce/" +[[menus.me]] + name = "LinkedIn" + url = "https://www.linkedin.com/in/alanpearceeu/" +[[menus.me]] + name = "Mastodon" + url = "https://ieji.de/@alanpearce" +[[menus.me]] + name = "BlueSky" + url = "https://bsky.app/profile/alanpearce.eu" diff --git a/content/LICENSE b/content/LICENSE new file mode 100644 index 0000000..4ea99c2 --- /dev/null +++ b/content/LICENSE @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/content/_index.md b/content/_index.md index add1ca4..7239667 100644 --- a/content/_index.md +++ b/content/_index.md @@ -1,9 +1,7 @@ +++ title = "Home" -sort_by = "date" -paginate_reversed = true +++ -<p class="p-note"> -I work as a Full-stack Developer in Berlin. I occasionally write about Emacs and +<p class="p-note note"> +I work as a back-end and infrastructure developer in Berlin. I occasionally write about Emacs and development-related topics. </p> diff --git a/content/post/_index.md b/content/post/_index.md deleted file mode 100644 index e0d2523..0000000 --- a/content/post/_index.md +++ /dev/null @@ -1,6 +0,0 @@ -+++ -title = "Posts" -sort_by = "date" -paginate_reversed = true -transparent = true -+++ diff --git a/content/post/homesteading.md b/content/post/homesteading.md new file mode 100644 index 0000000..52ce713 --- /dev/null +++ b/content/post/homesteading.md @@ -0,0 +1,13 @@ ++++ +title = "Homesteading" +description = "Running my own code" +date = 2023-09-22T10:09:22.141Z +[taxonomies] +tags = ["website"] ++++ + +I switched away from [Zola](https://www.getzola.org/) and made my own [static site builder](https://git.alanpearce.eu/website/tree) that uses only HTML templates. I've been wanting to do this since at least 2017, when I started to work on a [homestead project](https://git.alanpearce.eu/homestead/tree/src?h=2017), which I didn't quite finish. + +The recent release of [Bun](https://bun.sh/), which touts itself as an "all-in-one JavaScript toolkit" encouraged me to play around with it. I have to say, I am surprised by how energising it was; an antidote to the "JavaScript fatigue" I've read about and definitely experienced. + +I decided that I'd start by serving my site using Bun's web server, then I added site generation later. I have been intrigued by the idea of DOM templating ever since I read about it on [Camen Design](https://camendesign.com/dom_templating) [in 2012](https://camendesign.com/code/dom_templating/domtemplate_v4.rem) and I've enjoyed putting it into practice. diff --git a/content/post/nixos-on-nanopi-r5s.md b/content/post/nixos-on-nanopi-r5s.md new file mode 100644 index 0000000..185bd30 --- /dev/null +++ b/content/post/nixos-on-nanopi-r5s.md @@ -0,0 +1,142 @@ ++++ +title = "Running NixOS on a NanoPi R5S" +date = 2023-07-30T08:51:46Z +[taxonomies] +tags = ["nixos", "home-networking", "infrastructure"] ++++ + +I managed to get [NixOS](https://nixos.org) running on my [NanoPi R5S](https://www.friendlyelec.com/index.php?route=product/product&product_id=287) ([FriendlyElec Wiki](https://wiki.friendlyelec.com/wiki/index.php/NanoPi_R5S)). + +Firstly, I flashed a pre-built stock Debian image from [inindev](https://github.com/inindev/nanopi-r5) to an SD card. This can be used as a rescue system later on. + +From that SD card, I then flashed the same system onto the internal <abbr title="embedded MultiMediaCard">eMMC</abbr> Storage. I only really needed to this to ensure UBoot was correctly installed; I think there will be an easier way to do it. + +I had nix already installed on the <abbr title="Non-Volatile Memory Express">NVMe</abbr> <abbr title="Solid-State Drive">SSD</abbr> along with a home directory. I bind-mounted `/nix` and `/home` following the fstab I had previously set up: + +```conf +UUID=replaceme /mnt ext4 relatime,lazytime 0 2 +/mnt/nix /nix none defaults,bind 0 0 +/mnt/srv /srv none defaults,bind 0 0 +/mnt/home /home none defaults,bind 0 0 +``` + +I then created a user for myself using that home directory, I had full access to nix in the new Debian environment. This meant I had access to `nixos-install`. + +I wanted to use the [extlinux support in UBoot](https://u-boot.readthedocs.io/en/latest/develop/distro.html#boot-configuration-files), so I made `/mnt/boot` point to `/boot` on the <abbr>eMMC</abbr>: + +```sh +mkdir /mnt/{emmc,boot} +mount LABEL=rootfs /mnt/emmc +mount --bind /mnt/emmc /mnt/boot +``` + +<aside> +One could <em>probably</em> delete everything else on the <abbr>eMMC</abbr> and move the contents of <code>/mnt/emmc/boot</code> to <code>/mnt/emmc</code>, thus obviating the need to bind-mount <code>/boot</code> +</aside> + +I ran `nixos-generate-config` as usual, which set up the mount points in `hardware-configuration.nix` correctly. `configuration.nix` needed a bit of tweaking. My first booting configuration was something like this, mostly borrowed from [Artem Boldariev's comment](https://github.com/inindev/nanopi-r5/issues/11#issue-1789308883): + +```nix +{ config +, pkgs +, lib +, ... +}: +let + fsTypes = [ "f2fs" "ext" "exfat" "vfat" ]; +in +{ + imports = [ ./hardware-configuration.nix ]; + boot = { + kernelPackages = pkgs.linuxKernel.packages.linux_6_4; + + # partial Rockchip related changes from Debian 12 kernel version 6.1 + # Also, see here: + # https://discourse.nixos.org/t/how-to-provide-missing-headers-to-a-kernel-build/11422/3 + kernelPatches = [ + { + name = "rockchip-config.patch"; + patch = null; + extraConfig = '' + PHY_ROCKCHIP_PCIE Y + PCIE_ROCKCHIP_EP y + PCIE_ROCKCHIP_DW_HOST y + ROCKCHIP_VOP2 y + ''; + } + { + name = "status-leds.patch"; + patch = null; + # old: + # LEDS_TRIGGER_NETDEV y + extraConfig = '' + LED_TRIGGER_PHY y + USB_LED_TRIG y + LEDS_BRIGHTNESS_HW_CHANGED y + LEDS_TRIGGER_MTD y + ''; + } + ]; + + supportedFilesystems = fsTypes; + initrd.supportedFilesystems = fsTypes; + + initrd.availableKernelModules = [ + ## Rockchip + ## Storage + "sdhci_of_dwcmshc" + "dw_mmc_rockchip" + + "analogix_dp" + "io-domain" + "rockchip_saradc" + "rockchip_thermal" + "rockchipdrm" + "rockchip-rga" + "pcie_rockchip_host" + "phy-rockchip-pcie" + "phy_rockchip_snps_pcie3" + "phy_rockchip_naneng_combphy" + "phy_rockchip_inno_usb2" + "dwmac_rk" + "dw_wdt" + "dw_hdmi" + "dw_hdmi_cec" + "dw_hdmi_i2s_audio" + "dw_mipi_dsi" + ]; + loader = { + timeout = 3; + grub.enable = false; + generic-extlinux-compatible = { + enable = true; + useGenerationDeviceTree = true; + }; + }; + }; + # this file is from debian and should be in /boot/ + hardware.deviceTree.name = "../../rk3568-nanopi-r5s.dtb"; + # Most Rockchip CPUs (especially with hybrid cores) work best with "schedutil" + powerManagement.cpuFreqGovernor = "schedutil"; + + boot.kernelParams = [ + "console=tty1" + "console=ttyS2,1500000" + "earlycon=uart8250,mmio32,0xfe660000" + ]; + # Let's blacklist the Rockchips RTC module so that the + # battery-powered HYM8563 (rtc_hym8563 kernel module) will be used + # by default + boot.blacklistedKernelModules = [ "rtc_rk808" ]; + + # ... typical config omitted for brevity +} +``` + +Due to the custom kernel configuration, building takes a while. I set up a [distributed build](https://nixos.org/manual/nix/stable/advanced-topics/distributed-builds.html) to speed things up, using a [Hetzner Cloud](https://www.hetzner.com/cloud) CAX21 ARM64 instance (although I could have used an x86_64 system with one of the methods mentioned on the [NixOS on ARM NixOS wiki page](https://nixos.wiki/wiki/NixOS_on_ARM#Build_your_own_image_natively)). This made for a very long `nixos-install` command line: + +```sh +sudo env PATH=$PATH =nixos-install --root /mnt --no-channel-copy --channel https://nixos.org/channels/nixos-23.05 --option builders'ssh://my-host aarch64-linux /root/.ssh/id_pappel_nixpkgs 4 2 big-parallel' --option builders-use-substitutes true --max-jobs 0 +``` + +I added `setenv bootmeths "extlinux"` to `/boot/boot.txt` and ran `/boot/mkscr.sh` as root to ensure that UBoot would search for the `extlinux.conf` file diff --git a/content/post/now-on-three-continents.md b/content/post/now-on-three-continents.md new file mode 100644 index 0000000..1a2828a --- /dev/null +++ b/content/post/now-on-three-continents.md @@ -0,0 +1,25 @@ ++++ +title = "Now on three continents" +description = "This website is now hosted on three continents" +date = 2023-07-02T07:55:35Z +[taxonomies] +tags = ["website", "infrastructure"] ++++ + +This website is now hosted on three continents. + +I recently changed the hosting for this site to [fly](http://fly.io), since I was rather intrigued by the idea of being able to run three small <abbr>VMs</abbr> (<dfn id="VMs">Virtual Machines</dfn>) worldwide for free. I would gladly have paid a small amount for their services. If they didn't have a free allowance for <abbr>VMs</abbr> then it would only be around $6 a month, so I'm not worried about them removing the free allowance. + +Previously it was running on one [Hetzner](https://www.hetzner.com) <abbr title="Virtual Machine">VM</abbr> in Nuremberg, Germany that I set up and maintained myself. The maintenance wasn't a problem for me, but rather the idea of slow loading times for anyone reading this outside of Europe. + +American visitors should notice a definite speedup now, as there's a server on the west coast and for the few visitors in the Asia-Pacific region, there's also a server in Australia. I kept track of the response time before and after the change using the [Online or not](https://onlineornot.com/) [Do I need a CDN?](https://onlineornot.com/do-i-need-a-cdn) tool, which you can see in the table below (measured in <abbr title="milliseconds">ms</abbr>) + +| Region | Before | After | +|-------------------------|--------|-------| +| Europe (Frankfurt) | 62 | 32 | +| US East (N. Virginia) | 348 | 185 | +| US West (N. California) | 503 | 61 | +| Asia Pacific (Tokyo) | 732 | 251 | +| Asia Pacific (Sydney) | 1114 | 76 | + +I do find it rather amusing that I spend more time tinkering with the site than actually posting anything, but, for once, tinkering has actually led to me posting something (this post). I would like to think that this might encourage me to post more in the future, but only time will tell. diff --git a/content/post/repository-management-with-ghq.md b/content/post/repository-management-with-ghq.md index c225ace..dd21db9 100644 --- a/content/post/repository-management-with-ghq.md +++ b/content/post/repository-management-with-ghq.md @@ -67,10 +67,10 @@ sequence: bindkey '\es' cd-project-widget ``` -Now I can press `M-s` in a shell, start typing "dotfiles" and press enter to `cd` -to my [dotfiles][] project. Pretty neat! +Now I can press `M-s` in a shell, start typing "nixfiles" and press enter to `cd` +to my [nixfiles][] project. Pretty neat! [ghq]:https://github.com/motemen/ghq [fzf]:https://github.com/junegunn/fzf [fzf-cd-widget]:https://github.com/junegunn/fzf/blob/337cdbb37c1efc49b09b4cacc6e9ee1369c7d76d/shell/key-bindings.zsh#L40-L54 -[dotfiles]:https://git.alanpearce.eu/dotfiles +[nixfiles]:https://git.alanpearce.eu/dotfiles diff --git a/content/post/self-hosted-git.md b/content/post/self-hosted-git.md index 3bdbffb..ab88e78 100644 --- a/content/post/self-hosted-git.md +++ b/content/post/self-hosted-git.md @@ -144,4 +144,4 @@ I want, without consuming many system resources with daemons. [dotfiles-github]:https://github.com/alanpearce/dotfiles [wildrepos]:http://gitolite.com/gitolite/wild/ [ghq]:https://github.com/motemen/ghq -[using-ghq]:{{< relref "/post/repository-management-with-ghq.md" >}} "Repository management with ghq" +[using-ghq]:/post/repository-management-with-ghq/ "Repository management with ghq" diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..2cccff2 --- /dev/null +++ b/default.nix @@ -0,0 +1,10 @@ +(import + ( + let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in + fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; + sha256 = lock.nodes.flake-compat.locked.narHash; + } + ) + { src = ./.; } +).defaultNix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..95ede6a --- /dev/null +++ b/flake.lock @@ -0,0 +1,102 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": [ + "utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1710154385, + "narHash": "sha256-4c3zQ2YY4BZOufaBJB4v9VBBeN2dH7iVdoJw8SDNCfI=", + "owner": "tweag", + "repo": "gomod2nix", + "rev": "872b63ddd28f318489c929d25f1f0a3c6039c971", + "type": "github" + }, + "original": { + "owner": "tweag", + "repo": "gomod2nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1713596654, + "narHash": "sha256-LJbHQQ5aX1LVth2ST+Kkse/DRzgxlVhTL1rxthvyhZc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fd16bb6d3bcca96039b11aa52038fafeb6e4f4be", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-compat": "flake-compat", + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..b2e6708 --- /dev/null +++ b/flake.nix @@ -0,0 +1,60 @@ +{ + description = "My website, alanpearce.eu"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + inputs.utils.url = "github:numtide/flake-utils"; + inputs.flake-compat = { + url = "github:edolstra/flake-compat"; + flake = false; + }; + inputs.gomod2nix = { + url = "github:tweag/gomod2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "utils"; + }; + + outputs = { self, nixpkgs, utils, gomod2nix, ... }: + utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ gomod2nix.overlays.default ]; + }; + nativeBuildInputs = with pkgs; [ go ]; + packages = import ./nix/default.nix { + inherit pkgs self; + }; + commonShellPackages = with pkgs; [ + just + skopeo + flyctl + ]; + in + { + inherit packages; + devShells = { + ci = pkgs.mkShell { + packages = commonShellPackages; + }; + default = pkgs.mkShell { + inputsFrom = [ packages.builder ]; + packages = with pkgs; [ + gopls + gotools + go-tools + gomod2nix.packages.${system}.default + gci + netlify-cli + sentry-cli + ] ++ commonShellPackages; + }; + }; + checks = rec { + default = hyperlink; + hyperlink = pkgs.runCommandLocal "hyperlink" { } '' + ${pkgs.hyperlink}/bin/hyperlink ${packages.website}/website/public + touch $out + ''; + }; + }); +} diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..5b25a9d --- /dev/null +++ b/fly.toml @@ -0,0 +1,41 @@ +# fly.toml app configuration file generated for homestead on 2023-09-14T11:40:37+02:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = "alanpearce-eu" +primary_region = "ams" + +[build] + image = "registry.fly.io/alanpearce-eu" + +[env] + PORT = "80" + REDIRECT_OTHER_HOSTNAMES = "true" + BASE_URL = "https://alanpearce.eu" + +[metrics] + port = 9091 + path = "/metrics" + +[http_service] + internal_port = 80 + force_https = true + auto_stop_machines = false + auto_start_machines = true + min_machines_running = 3 + processes = ["app"] + [http_service.concurrency] + type = "requests" + hard_limit = 20000 + soft_limit = 15000 +[http_service.http_options.response] + pristine = true +[[http_service.checks]] + grace_period = "15s" + interval = "30s" + method = "GET" + timeout = "1s" + path = "/health" + [http_service.checks.headers] + Host = "fly-internal" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d124c22 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module website + +go 1.22.1 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/PuerkitoBio/goquery v1.9.1 + github.com/a-h/htmlformat v0.0.0-20231108124658-5bd994fe268e + github.com/adrg/frontmatter v0.2.0 + github.com/antchfx/xmlquery v1.4.0 + github.com/antchfx/xpath v1.3.0 + github.com/ardanlabs/conf/v3 v3.1.7 + github.com/deckarep/golang-set/v2 v2.6.0 + github.com/getsentry/sentry-go v0.27.0 + github.com/otiai10/copy v1.14.0 + github.com/pkg/errors v0.9.1 + github.com/shengyanli1982/law v0.1.13 + github.com/yuin/goldmark v1.7.1 + golang.org/x/net v0.24.0 +) + +replace github.com/a-h/htmlformat => github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562 + +require ( + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..68fbd04 --- /dev/null +++ b/go.sum @@ -0,0 +1,111 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= +github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= +github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= +github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= +github.com/alanpearce/htmlformat v0.0.0-20240418170242-387207ca8d01 h1:mD01zfZPrqHj7OlyU1O2gJIBRN/kgIyMueres3CHPp8= +github.com/alanpearce/htmlformat v0.0.0-20240418170242-387207ca8d01/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0= +github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562 h1:7LpBXZnmFk8+RwdFnAYB7rKZhBQrQ4poPLEhpwwbmSc= +github.com/alanpearce/htmlformat v0.0.0-20240425000139-1244374b2562/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/antchfx/xmlquery v1.4.0 h1:xg2HkfcRK2TeTbdb0m1jxCYnvsPaGY/oeZWTGqX/0hA= +github.com/antchfx/xmlquery v1.4.0/go.mod h1:Ax2aeaeDjfIw3CwXKDQ0GkwZ6QlxoChlIBP+mGnDFjI= +github.com/antchfx/xpath v1.3.0 h1:nTMlzGAK3IJ0bPpME2urTuFL76o4A96iYvoKFHRXJgc= +github.com/antchfx/xpath v1.3.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/ardanlabs/conf/v3 v3.1.7 h1:p232cF68TafoA5U9ZlbxUIhGJtGNdKHBXF80Fdqb5t0= +github.com/ardanlabs/conf/v3 v3.1.7/go.mod h1:zclexWKe0NVj6LHQ8NgDDZ7bQ1spE0KeKPFficdtAjU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= +github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/shengyanli1982/law v0.1.13 h1:BuUYw/71w1dpGnbXLaCyFUHT36wueUQ7AoVephDut4E= +github.com/shengyanli1982/law v0.1.13/go.mod h1:20k9YnOTwilUB4X5Z4S7TIX5Ek1Ok4xfx8V8ZxIWlyM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/atom/atom.go b/internal/atom/atom.go new file mode 100644 index 0000000..f2ca4a9 --- /dev/null +++ b/internal/atom/atom.go @@ -0,0 +1,43 @@ +package atom + +import ( + "encoding/xml" + "time" + + . "website/internal/config" +) + +func MakeTagURI(config Config, specific string) string { + return "tag:" + config.OriginalDomain + "," + config.DomainStartDate + ":" + specific +} + +type Link struct { + XMLName xml.Name `xml:"link"` + Rel string `xml:"rel,attr"` + Type string `xml:"type,attr"` + Href string `xml:"href,attr"` +} + +func MakeLink(url string) Link { + return Link{ + Rel: "alternate", + Type: "text/html", + Href: url, + } +} + +type FeedContent struct { + Content string `xml:",innerxml"` + Type string `xml:"type,attr"` +} + +type FeedEntry struct { + XMLName xml.Name `xml:"entry"` + Title string `xml:"title"` + Link Link `xml:"link"` + Id string `xml:"id"` + Updated time.Time `xml:"updated"` + Summary string `xml:"summary,omitempty"` + Content FeedContent `xml:"content"` + Author string `xml:"author>name"` +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d2eabf0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "io/fs" + "log/slog" + "net/url" + "os" + "strconv" + + "github.com/BurntSushi/toml" + "github.com/pkg/errors" +) + +type Taxonomy struct { + Name string + Feed bool +} + +type MenuItem struct { + Name string + URL string `toml:"url"` +} + +type URL struct { + *url.URL +} + +func (u *URL) UnmarshalText(text []byte) (err error) { + u.URL, err = url.Parse(string(text)) + return err +} + +type Config struct { + DefaultLanguage string `toml:"default_language"` + BaseURL URL `toml:"base_url"` + RedirectOtherHostnames bool `toml:"redirect_other_hostnames"` + Port uint64 + Production bool + Title string + Email string + Description string + DomainStartDate string `toml:"domain_start_date"` + OriginalDomain string `toml:"original_domain"` + Taxonomies []Taxonomy + Extra struct { + Headers map[string]string + } + Menus map[string][]MenuItem +} + +func getEnvFallback(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } else { + return fallback + } +} + +func GetConfig() (*Config, error) { + config := Config{} + slog.Debug("reading config.toml") + _, err := toml.DecodeFile("config.toml", &config) + if err != nil { + var pathError *fs.PathError + var tomlError toml.ParseError + if errors.As(err, &pathError) { + return nil, errors.WithMessage(err, "could not read configuration") + } else if errors.As(err, &tomlError) { + return nil, errors.WithMessage(err, tomlError.ErrorWithUsage()) + } else { + return nil, errors.Wrap(err, "config error") + } + } + port, err := strconv.ParseUint(getEnvFallback("PORT", "3000"), 10, 16) + if err != nil { + return nil, err + } + config.Port = port + return &config, nil +} diff --git a/justfile b/justfile new file mode 100755 index 0000000..e4021c0 --- /dev/null +++ b/justfile @@ -0,0 +1,72 @@ +#! /usr/bin/env -S nix develop . --command just --justfile + +fly-system := "x86_64-linux" +fly-registry := "registry.fly.io/alanpearce-eu" +docker-tag := env_var_or_default("DOCKER_TAG", `git rev-parse HEAD`) +version := `sentry-cli releases propose-version` +environment := "production" +started-at := `date +%s` + +default: + @just --list --justfile {{ justfile() }} --unsorted + +check: + nix flake check . --print-build-logs + +check-licenses: + nix run nixpkgs#go-licenses check ./... + +update-all: + go get -u all + gomod2nix --outdir nix + nix flake update + +watch-flake command: + watchexec --restart -w flake.nix -w flake.lock direnv exec . {{ command }} + +watch-builder: (watch-flake "watchexec -w cmd/build -w content -w templates -r go run ./cmd/build --base-url http://localhost:3000") + +generate: + go run ./cmd/build --base-url http://localhost:3000 + +nix-build what: + nix build .#{{ what }} + +watch-server: (watch-flake "watchexec -r -i content -i templates go run ./cmd/server") + +docker-stream system=(arch() + "-linux"): + @nix build --print-out-paths .#docker-stream-{{ system }} | sh + +docker-image system=(arch() + "-linux"): + nix build .#docker-image-{{ system }} + +docker-stream-fly: + just docker-stream {{ fly-system }} + +docker-image-fly: (docker-image fly-system) + +docker-inspect image-path="result" *skopeo-flags="": + skopeo {{ skopeo-flags }} inspect docker-archive:{{ image-path }} + +print-docker-tag: + @echo {{ fly-registry }}:{{ docker-tag }} + +stream-to-registry *skopeo-flags="": sentry-create-release && sentry-finalise-release + just docker-stream-fly | gzip --fast | skopeo {{ skopeo-flags }} copy --dest-precompute-digests docker-archive:/dev/stdin docker://{{ fly-registry }}:{{ docker-tag }} + +result := `readlink -f result` +push-to-registry *skopeo-flags="": + skopeo {{ skopeo-flags }} copy --dest-precompute-digests docker-archive://{{ result }} docker://{{ fly-registry }}:{{ docker-tag }} + +sentry-create-release: + sentry-cli releases new {{ version }} + +sentry-finalise-release: + sentry-cli releases set-commits {{ version }} --ignore-missing --auto # will not work in CI + sentry-cli releases finalize {{ version }} + +sentry-create-deploy: + sentry-cli releases deploys {{ version }} new --started {{ started-at }} --finished `date +%s` --env {{ environment }} + +deploy registry-and-tag=(fly-registry + ":" + docker-tag): && sentry-create-deploy + fly deploy --image {{ registry-and-tag }} diff --git a/netlify/netlify.toml b/netlify/netlify.toml new file mode 100644 index 0000000..7b203eb --- /dev/null +++ b/netlify/netlify.toml @@ -0,0 +1,14 @@ +[build] + base = "netlify" + publish = "." + +[[redirects]] + from = "*" + to = "https://alanpearce.eu/:splat" + status = 301 + force = true + +[[headers]] + for = "/*" + [headers.values] + cache-control = "max-age=86400" diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..e818c5a --- /dev/null +++ b/nix/default.nix @@ -0,0 +1,102 @@ +{ pkgs, self }: +let + version = "unstable"; + shortSHA = self.shortRev or self.dirtyShortRev; + fullSHA = self.rev or self.dirtyRev; + mkDocker = type: { server, website }: + let + PORT = 80; + in + pkgs.dockerTools.${type} { + name = "registry.fly.io/alanpearce-eu"; + tag = fullSHA; + contents = [ + (pkgs.writeTextDir "config.toml" (builtins.readFile ./../config.toml)) + website + ]; + config = { + Cmd = [ "${server}/bin/server" ]; + Env = [ + "PRODUCTION=true" + "LISTEN_ADDRESS=::" + "PORT=${builtins.toString PORT}" + ]; + ExposedPorts = { + "${builtins.toString PORT}/tcp" = { }; + }; + }; + }; + mkDockerStream = mkDocker "streamLayeredImage"; + mkDockerImage = mkDocker "buildLayeredImage"; +in +with pkgs; rec { + default = server; + builder = buildGoApplication { + pname = "website-builder"; + inherit version; + CGO_ENABLED = 0; + src = with lib.fileset; toSource { + root = ./..; + fileset = unions [ + ./../go.mod + ./../go.sum + ./../cmd/build + ./../internal + ]; + }; + modules = ./gomod2nix.toml; + subPackages = [ "cmd/build" ]; + }; + website = runCommandLocal "build" + { + src = with lib.fileset; toSource { + root = ./..; + fileset = unions [ + ./../config.toml + ./../content + ./../static + ./../templates + ]; + }; + } '' + ${builder}/bin/build -s $src -d $out/website + ''; + server = buildGoApplication { + pname = "server"; + inherit version; + CGO_ENABLED = 0; + src = with lib.fileset; toSource { + root = ./..; + fileset = unions [ + ./../go.mod + ./../go.sum + ./../cmd/server + ./../internal + ]; + }; + modules = ./gomod2nix.toml; + subPackages = [ "cmd/server" ]; + ldflags = [ + "-s" + "-w" + "-X" + "main.CommitSHA=${fullSHA}" + "-X" + "main.ShortSHA=${shortSHA}" + ]; + }; + docker-stream = mkDockerStream { inherit server website; }; + docker-stream-aarch64-linux = mkDockerStream { + inherit website; server = (self.packages.aarch64-linux.server); + }; + docker-stream-x86_64-linux = mkDockerStream { + inherit website; server = (self.packages.x86_64-linux.server); + }; + docker-image = mkDockerImage { inherit server website; }; + docker-image-aarch64-linux = mkDockerImage { + inherit website; server = (self.packages.aarch64-linux.server); + }; + docker-image-x86_64-linux = mkDockerImage { + inherit website; server = (self.packages.x86_64-linux.server); + }; +} diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml new file mode 100644 index 0000000..1e2501d --- /dev/null +++ b/nix/gomod2nix.toml @@ -0,0 +1,73 @@ +schema = 3 + +[mod] + [mod."github.com/BurntSushi/toml"] + version = "v1.3.2" + hash = "sha256-FIwyH67KryRWI9Bk4R8s1zFP0IgKR4L66wNQJYQZLeg=" + [mod."github.com/PuerkitoBio/goquery"] + version = "v1.9.1" + hash = "sha256-HlO8KL0FWs7qZk56wcVAn/y080PfK910HyIVo9y9lvM=" + [mod."github.com/a-h/htmlformat"] + version = "v0.0.0-20240425000139-1244374b2562" + hash = "sha256-qvnbf/VCR2s2VmyPaQeHLkpA01MNy1g1U0l9B9maBcE=" + replaced = "github.com/alanpearce/htmlformat" + [mod."github.com/adrg/frontmatter"] + version = "v0.2.0" + hash = "sha256-WJsVcdCpkIkjqUz5fJOFStZYwQlrcFzQ6+mZatZiimo=" + [mod."github.com/andybalholm/cascadia"] + version = "v1.3.2" + hash = "sha256-Nc9SkqJO/ecincVcUBFITy24TMmMGj5o0Q8EgdNhrEk=" + [mod."github.com/antchfx/xmlquery"] + version = "v1.4.0" + hash = "sha256-ReWP6CPDvvWUd7vY0qIP4qyxvrotXrx9HXbGbeProP4=" + [mod."github.com/antchfx/xpath"] + version = "v1.3.0" + hash = "sha256-SU+Tnf5c9vsDCrY1BVKjqYLhB91xt9oHBS5bicbs2cA=" + [mod."github.com/ardanlabs/conf/v3"] + version = "v3.1.7" + hash = "sha256-7H53l0JN5Q6hkAgBivVQ8lFd03oNmP1IG8ihzLKm2CQ=" + [mod."github.com/deckarep/golang-set/v2"] + version = "v2.6.0" + hash = "sha256-ni1XK75Q8iBBmxgoyZTedP4RmrUPzFC4978xB4HKdfs=" + [mod."github.com/getsentry/sentry-go"] + version = "v0.27.0" + hash = "sha256-PTkTzVNogqFA/5rc6INLY6RxK5uR1AoJFOO+pOPdE7Q=" + [mod."github.com/golang/groupcache"] + version = "v0.0.0-20210331224755-41bb18bfe9da" + hash = "sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0=" + [mod."github.com/kr/pretty"] + version = "v0.3.1" + hash = "sha256-DlER7XM+xiaLjvebcIPiB12oVNjyZHuJHoRGITzzpKU=" + [mod."github.com/otiai10/copy"] + version = "v1.14.0" + hash = "sha256-xsaL1ddkPS544y0Jv7u/INUALBYmYq29ddWvysLXk4A=" + [mod."github.com/pkg/errors"] + version = "v0.9.1" + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" + [mod."github.com/rogpeppe/go-internal"] + version = "v1.10.0" + hash = "sha256-vR7+d0aoKTuKeTYSgZxsGhH9e5Zvxix3Zrq9SPm5+NQ=" + [mod."github.com/shengyanli1982/law"] + version = "v0.1.13" + hash = "sha256-gjXWxWR6XCpOUYKBzPaObw2hPOmkoVtuHd1aMHm/ljA=" + [mod."github.com/yuin/goldmark"] + version = "v1.7.1" + hash = "sha256-3EUgwoZRRs2jNBWSbB0DGNmfBvx7CeAgEwyUdaRaeR4=" + [mod."golang.org/x/net"] + version = "v0.24.0" + hash = "sha256-w1c21ljta5wNIyel9CSIn/crPzwOCRofNKhqmfs4aEQ=" + [mod."golang.org/x/sync"] + version = "v0.7.0" + hash = "sha256-2ETllEu2GDWoOd/yMkOkLC2hWBpKzbVZ8LhjLu0d2A8=" + [mod."golang.org/x/sys"] + version = "v0.19.0" + hash = "sha256-cmuL31TYLJmDm/fDnI2Sn0wB88cpdOHV1+urorsJWx4=" + [mod."golang.org/x/text"] + version = "v0.14.0" + hash = "sha256-yh3B0tom1RfzQBf1RNmfdNWF1PtiqxV41jW1GVS6JAg=" + [mod."gopkg.in/check.v1"] + version = "v1.0.0-20201130134442-10cb98267c6c" + hash = "sha256-VlIpM2r/OD+kkyItn6vW35dyc0rtkJufA93rjFyzncs=" + [mod."gopkg.in/yaml.v2"] + version = "v2.4.0" + hash = "sha256-uVEGglIedjOIGZzHW4YwN1VoRSTK8o0eGZqzd+TNdd0=" diff --git a/static/cv/index.html b/static/cv/index.html new file mode 100644 index 0000000..4fef4cc --- /dev/null +++ b/static/cv/index.html @@ -0,0 +1,348 @@ +<!doctype html> +<html> + <head> + <title>Alan Pearce's Curriculum Vitae</title> + <style> + body { + font-family: Verdana, sans-serif; + font-size: small; + margin: auto; + padding: 1em; + max-width: 50rem; + text-align: left; + background-color: #fff; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.5; + color: #444; + height: 210mm; + width: 297mm; + } + + @page { + size: A4 portrait; + } + + h1, + h2, + h3, + h4, + h5, + h6, + strong, + b { + color: #222; + margin: unset; + } + + a { + color: #3273dc; + } + + .title { + color: #222; + text-decoration: none; + border: 0; + } + + time { + font-style: italic; + } + + nav a { + margin-right: 1ex; + } + + .tags { + padding: unset; + font-size: smaller; + } + + .tags > li { + list-style: none; + display: inline-block; + padding-right: 1ex; + } + + textarea { + width: 100%; + font-size: 1rem; + } + + input { + font-size: 1rem; + } + + main, + article { + line-height: 1.6; + } + + blockquote { + border-left: 1px solid #999; + color: #222; + padding-left: 20px; + font-style: italic; + } + + footer { + padding: 25px; + text-align: center; + } + + main { + column-count: 2; + } + main > section { + padding-right: 1rem; + padding: 1rem 0; + border-bottom: 2px solid #999; + break-inside: avoid; + } + section > header { + display: flex; + justify-content: space-between; + align-items: center; + } + .timeperiod { + font-style: italic; + font-size: small; + } + + ul { + padding-left: 0; + margin: unset; + } + ul > li { + display: inline-block; + font-size: smaller; + } + + .links > li { + display: block; + } + + @media (prefers-color-scheme: dark) { + body { + background-color: #333; + color: #ddd; + } + + h1, + h2, + h3, + h4, + h5, + h6, + strong, + b, + .title { + color: #eee; + } + + a { + color: #8cc2dd; + } + blockquote { + color: #ccc; + } + section { + border-bottom-color: #ccc; + } + } + </style> + </head> + <body> + <header> + <h1>Alan Pearce</h1> + <h2>Backend & Infrastructure Developer</h2> + <h3>Berlin, Germany</h3> + </header> + <hr /> + <main> + <section> + <ul class="links"> + <li> + Email: <a href="mailto:alan@alanpearce.eu">alan@alanpearce.eu</a> + </li> + <li>Website: <a href="https://alanpearce.eu">alanpearce.eu</a></li> + <li> + GitHub: <a href="https://github.com/alanpearce">@alanpearce</a> + </li> + <li> + Personal Projects: + <a href="https://git.alanpearce.eu">git.alanpearce.eu</a> + </li> + </ul> + </section> + <section> + <h4>Summary</h4> + <p> + I care about keeping code and UIs consistent and simple. I also have a + strong drive to learn and really enjoy being able to explore new + methodologies and languages. + </p> + </section> + <section> + <h4>Experience</h4> + <header> + <h5>Senior Fullstack Developer at SatoshiPay</h5> + <span class="timeperiod">2017—2023</span> + </header> + <ul> + <li>Helm</li> + <li>Kubernetes</li> + <li>GitLab</li> + <li>TypeScript</li> + <li>PostgreSQL</li> + </ul> + <p> + Principal worker for migration from Docker Cloud to Kubernetes, + alongside work on microservices interfacing with distributed ledger + APIs. Implemented and maintained GitLab CI/CD pipelines including + merge request previews and end-to-end testing. Migrated projects to + product-based monorepos. + </p> + </section> + <section> + <header> + <h5>Senior Fullstack Developer at SpotCap</h5> + <span class="timeperiod">2015–2017</span> + </header> + <ul> + <li>NodeJS</li> + <li>MySQL</li> + <li>Webpack</li> + <li>Sails.js</li> + <li>Mithril.js</li> + </ul> + <p> + Responsible for banking integration service, implemented parsers and + generators for custom text formats (MT940, MT942) using unit tests to + verify. + <br /> + Worked on backend credit scoring admin panel, began migration from + Sails to SPA using Mithril + </p> + </section> + <section> + <header> + <h5>Senior Web Developer at StudentCrowd (Studio-40 spin-off)</h5> + <span class="timeperiod">2014–2015</span> + </header> + <ul> + <li>PHP</li> + <li>MySQL</li> + <li>ElasticSearch</li> + <li>Vagrant</li> + <li>Saltstack</li> + </ul> + <p> + Optimised database access and ORM usage. Simplified dev environment + setup using Vagrant and Salt. Attended ElasticSearch, LogStash & + Kibana training. Worked remotely (60% -> 100%) + </p> + </section> + <section> + <header> + <h5>Senior Developer at Studio-40</h5> + <span class="timeperiod">2014</span> + </header> + <ul> + <li>Symfony</li> + <li>Sylius</li> + <li>PHP</li> + <li>MySQL</li> + <li>Capistrano</li> + </ul> + <p> + Wrote product CSV importer for Sylius with streaming preview diff + feature. Fixed issues with integration of payment provider API + including false payment failures. Assisted front-end developers with + JavaScript. + </p> + </section> + <section> + <header> + <h5>Backend Web Developer at Bulb Studios</h5> + <span class="timeperiod">2013–2014</span> + </header> + <ul> + <li>Laravel</li> + <li>ExpressionEngine</li> + <li>Ansible</li> + <li>PHP</li> + <li>Capistrano</li> + </ul> + <p> + Suggested and implemented switch from Apache to Nginx, enabling a + 1000x speedup in page loads. Suggested and implemented use of + configuration management for server provisioning. Introduced Vagrant + to reduce development environment variance and Capistrano for + deployment. Created time-basic competition entry API designed for 50k + RPM. + </p> + </section> + <section> + <header> + <h5>PHP Web Developer at Supplyant</h5> + <span class="timeperiod">2012-2013</span> + </header> + <ul> + <li>PHP</li> + <li>MySQL</li> + <li>Subversion</li> + <li>jQuery</li> + <li>HTML</li> + <li>CSS</li> + </ul> + <p> + Maintained e-commerce platform and worked on new product management + system. Made Entity-Attribute-Value system usable for other database + consumers using an SQL view. Recommended use of Mustache templates, + which the design team loved + </p> + </section> + <section> + <header> + <h5>Web Applications Programmer at ASL Holdings</h5> + <span class="timeperiod">2010-2011</span> + </header> + <ul> + <li>PHP</li> + <li>MySQL</li> + <p>Continued rewrite of SIM management web application</p> + </ul> + </section> + <section> + <h4>Relevant Education</h4> + <div> + <header> + <h5>CodeSchool</h5> + <span class="timeperiod">2014</span> + </header> + <ul> + <li>Ruby</li> + <li>JavaScript</li> + <li>CoffeeScript</li> + <li>EmberJS</li> + <li>BackboneJS</li> + </ul> + </div> + <div> + <header> + <h5>Computing A Level at Northampton College</h5> + <span class="timeperiod">2008-2010</span> + </header> + </div> + <ul> + <li>Pascal</li> + <li>PHP</li> + <li>SQL</li> + <li>HTML</li> + <li>CSS</li> + </ul> + </section> + </main> + </body> +</html> diff --git a/static/robots.txt b/static/robots.txt index ef30e6f..a0e9740 100644 --- a/static/robots.txt +++ b/static/robots.txt @@ -1,7 +1,4 @@ User-agent: * Disallow: Host: alanpearce.eu -Sitemap: https://alanpearce.eu/sitemap.xml -User-agent: googlebot -Disallow: / diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..eade0f9 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,38 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Site Title</title> + <meta name="referrer" content="no-referrer-when-downgrade" /> + <link + rel="alternate" + type="application/atom+xml" + title="" + href="/atom.xml" + /> + <style></style> + </head> + <body> + <a class="skip" href="#main">Skip to main content</a> + <header> + <h2> + <a href="/" class="title">Site title</a> + </h2> + <nav> + <a href="/">Home</a> + </nav> + </header> + <main id="main"> + <h1>404</h1> + <h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2> + </main> + <footer> + Content is + <a rel="license" href="http://creativecommons.org/licenses/by/4.0/" + >CC BY 4.0</a + >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is + <a href="https://opensource.org/licenses/MIT">MIT</a> + </footer> + </body> +</html> diff --git a/templates/atom.xml b/templates/atom.xml new file mode 100644 index 0000000..81c9a76 --- /dev/null +++ b/templates/atom.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<?xml-stylesheet href="/feed-styles.xsl" type="text/xsl"?> +<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="{{ lang }}"> + <title>{{ config.title }} + {%- if term %} - {{ term.name }} + {%- elif section.title %} - {{ section.title }} + {%- endif -%} + </title> + {%- if config.description %} + <subtitle>{{ config.description }}</subtitle> + {%- endif %} + <link href="{{ feed_url | safe }}" rel="self" type="application/atom+xml"/> + <link href=" + {%- if section -%} + {{ section.permalink | escape_xml | safe }} + {%- else -%} + {{ config.base_url | escape_xml | safe }} + {%- endif -%} + "/> + <generator uri="https://www.getzola.org/">Zola</generator> + <updated>{{ last_updated | date(format="%+") }}</updated> + <id>{{ feed_url | safe }}</id> + {%- for page in pages %} + <entry xml:lang="{{ page.lang }}"> + <title>{{ page.title }}</title> + <published>{{ page.date | date(format="%+") }}</published> + <updated>{{ page.updated | default(value=page.date) | date(format="%+") }}</updated> + <author> + <name> + {%- if page.authors -%} + {{ page.authors[0] }} + {%- elif config.author -%} + {{ config.author }} + {%- else -%} + Unknown + {%- endif -%} + </name> + </author> + <link rel="alternate" href="{{ page.permalink | safe }}" type="text/html"/> + <id>{{ page.permalink | safe }}</id> + {% if page.summary %} + <summary type="html">{{ page.summary }}</summary> + {% else %} + <content type="html">{{ page.content }}</content> + {% endif %} + </entry> + {%- endfor %} +</feed> diff --git a/templates/count.html b/templates/count.html new file mode 100644 index 0000000..737b99d --- /dev/null +++ b/templates/count.html @@ -0,0 +1,6 @@ +<body> + <script data-goatcounter="https://alanpearce-eu.goatcounter.com/count" async src="https://gc.zgo.at/count.js"></script> + <noscript> + <img src="https://alanpearce-eu.goatcounter.com/count?p=/INSERT-PAGE-HERE" /> + </noscript> +</body> diff --git a/templates/feed-styles.xsl b/templates/feed-styles.xsl new file mode 100644 index 0000000..5953f89 --- /dev/null +++ b/templates/feed-styles.xsl @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="utf-8"?> +<xsl:stylesheet + version="3.0" + xmlns:xsl="http://www.w3.org/1999/XSL/Transform" + xmlns:atom="http://www.w3.org/2005/Atom" +> + <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" /> + <xsl:template match="/"> + <html xmlns="http://www.w3.org/1999/xhtml" lang="en"> + <head> + <title>RSS Feed | <xsl:value-of select="/atom:feed/atom:title"/></title> + <meta charset="utf-8" /> + <meta http-equiv="content-type" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <style></style> + </head> + <body> + <main> + <div class="helptext"> + <strong>This is an RSS feed</strong>. Subscribe by copying the URL + from the address bar into your newsreader. Visit + <a href="https://aboutfeeds.com">About Feeds</a> + to learn more and get started. It's free. + </div> + <div> + <h1> + <!-- https://commons.wikimedia.org/wiki/File:Feed-icon.svg --> + <svg + xmlns="http://www.w3.org/2000/svg" + version="1.1" + style="width: 1.5ex; height: 1.5ex" + viewBox="0 0 256 256" + > + <rect width="256" height="256" x="0" y="0" fill="#7F7F7F" /> + <rect width="246" height="246" x="5" y="5" fill="#A0A0A0" /> + <rect width="236" height="236" x="10" y="10" fill="#A6A6A6" /> + <circle cx="68" cy="189" r="24" fill="#FFF" /> + <path + d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z" + fill="#FFF" + /> + <path + d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z" + fill="#FFF" + /> + </svg> + RSS Feed Preview | + <span> + <xsl:value-of select="/atom:feed/atom:title" /> + </span> + </h1> + <nav> + <a> + <xsl:attribute name="href"> + <xsl:value-of select="/atom:feed/atom:link[1]/@href" /> + </xsl:attribute> + Visit Website + </a> + </nav> + <ul class="h-feed"> + <xsl:for-each select="/atom:feed/atom:entry"> + <li class="h-entry"> + <span> + <time class="dt-published"> + <xsl:value-of select="substring(atom:updated, 0, 11)" /> + </time> + </span> + <a class="p-name u-url"> + <xsl:attribute name="href"> + <xsl:value-of select="atom:link/@href" /> + </xsl:attribute> + <xsl:value-of select="atom:title" /> + </a> + </li> + </xsl:for-each> + </ul> + </div> + </main> + </body> + </html> + </xsl:template> +</xsl:stylesheet> diff --git a/templates/feed.xml b/templates/feed.xml new file mode 100644 index 0000000..ddc90dd --- /dev/null +++ b/templates/feed.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<?xml-stylesheet href="/feed-styles.xsl" type="text/xsl"?> +<feed xmlns="http://www.w3.org/2005/Atom"> + <title>Example Feed</title> + <link href="http://example.org/"></link> + <id>urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6</id> + <updated>2003-12-13T18:30:02Z</updated> + <entry> + <title>Atom-Powered Robots Run Amok</title> + <link rel="alternate" type="text/html" href="http://example.org/2003/12/13/atom03.html"></link> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id> + <updated>2003-12-13T18:30:02Z</updated> + <summary>Some text.</summary> + <content type="html"> + <div> + <p>This is the entry content.</p> + </div> + </content> + <author> + <name>John Doe</name> + </author> + </entry> + +</feed> diff --git a/templates/homepage.html b/templates/homepage.html new file mode 100644 index 0000000..d256e8c --- /dev/null +++ b/templates/homepage.html @@ -0,0 +1,64 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Site Title</title> + <meta name="referrer" content="no-referrer-when-downgrade" /> + <link + rel="alternate" + type="application/atom+xml" + title="" + href="/atom.xml" + /> + <link href="" rel="canonical" /> + <style></style> + </head> + <body> + <a class="skip" href="#main">Skip to main content</a> + <header> + <h2> + <a href="/" class="title">Site title</a> + </h2> + <nav> + <a href="/">Home</a> + </nav> + </header> + <main id="main"> + <div id="content"></div> + <section> + <h2>Latest Posts</h2> + <ul class="h-feed"> + <li class="h-entry"> + <span> + <time class="dt-published" datetime="2000-12-31T12:33:02+02:00"> + 2000-12-31 + </time> + </span> + <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a> + </li> + </ul> + </section> + <section> + <h2>Elsewhere on the Internet</h2> + <ul class="elsewhere"> + <li> + <a class="u-email" rel="me" href="mailto:user@example.com" + >user@example.com</a + > + </li> + <li> + <a class="u-url" rel="me" href="http://example.com">Example</a> + </li> + </ul> + </section> + </main> + <footer> + Content is + <a rel="license" href="http://creativecommons.org/licenses/by/4.0/" + >CC BY 4.0</a + >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is + <a href="https://opensource.org/licenses/MIT">MIT</a> + </footer> + </body> +</html> diff --git a/templates/list.html b/templates/list.html new file mode 100644 index 0000000..74d6576 --- /dev/null +++ b/templates/list.html @@ -0,0 +1,53 @@ +<!doctype html> +<html lang="en-GB"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Site Title</title> + <meta name="referrer" content="no-referrer-when-downgrade" /> + <link + rel="alternate" + type="application/atom+xml" + title="Site Title" + href="/atom.xml" + /> + <link href="" rel="canonical" /> + <style></style> + </head> + <body> + <a class="skip" href="#content">Skip to main content</a> + <header> + <h2> + <a href="/" class="title">Site Title</a> + </h2> + <nav> + <a href="/">Home</a> + </nav> + </header> + <main id="content"> + <div class="filter"> + <h3 class="filter">Tag</h3> + <small> + <a href="../">Remove filter</a> + </small> + </div> + <ul class="h-feed"> + <li class="h-entry"> + <span> + <time class="dt-published" datetime="2000-12-31T12:33:02+02:00"> + 2000-12-31 + </time> + </span> + <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a> + </li> + </ul> + </main> + <footer> + Content is + <a rel="license" href="http://creativecommons.org/licenses/by/4.0/" + >CC BY 4.0</a + >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is + <a href="https://opensource.org/licenses/MIT">MIT</a> + </footer> + </body> +</html> diff --git a/templates/post.html b/templates/post.html new file mode 100644 index 0000000..7574a1f --- /dev/null +++ b/templates/post.html @@ -0,0 +1,79 @@ +<!doctype html> +<html lang="en-GB"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title></title> + <meta name="referrer" content="no-referrer-when-downgrade" /> + <link + rel="alternate" + type="application/atom+xml" + title="" + href="/atom.xml" + /> + <link href="" rel="canonical" /> + <style></style> + </head> + <body> + <a class="skip" href="#main">Skip to main content</a> + <header> + <h2> + <a href="/" class="title"></a> + </h2> + <nav> + <a href="/">Home</a> + </nav> + </header> + <main id="main"> + <article class="h-entry"> + <h1 class="p-name">Post Title</h1> + <p> + <time class="dt-published">2000-12-31</time> + </p> + <div class="e-content"> + Enim lobortis scelerisque fermentum dui faucibus in ornare quam + viverra. Eget egestas purus viverra accumsan in nisl nisi, scelerisque + eu ultrices vitae, auctor eu augue ut lectus arcu, bibendum at. + + <code>/bin/test</code> + + <pre> + <code class="language-conf"> +foo=bar + </code> + </pre> + + <table> + <thead> + <tr> + <th>One</th> + <th>Two</th> + <th>Three</th> + </tr> + </thead> + <tbody> + <tr> + <td>1</td> + <td>2</td> + <td>3</td> + </tr> + </tbody> + </table> + </div> + <div class="tags"> + Tags: + <ul class="p-categories tags"> + <li><a class="p-category" href="/tags/sample/">#sample</a></li> + </ul> + </div> + </article> + </main> + <footer> + Content is + <a rel="license" href="http://creativecommons.org/licenses/by/4.0/" + >CC BY 4.0</a + >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is + <a href="https://opensource.org/licenses/MIT">MIT</a> + </footer> + </body> +</html> diff --git a/templates/style.css b/templates/style.css new file mode 100644 index 0000000..8d21237 --- /dev/null +++ b/templates/style.css @@ -0,0 +1,195 @@ +body { + font-family: Verdana, sans-serif; + margin: auto; + padding: 1em; + max-width: 50rem; + text-align: left; + background-color: #fff; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.5; + color: #444; +} + +.skip { + position: absolute; + top: -3em; + background: #fff; +} +.skip:focus { + top: 0; +} + +h1, +h2, +h3, +h4, +h5, +h6, +strong, +b { + color: #222; +} + +a { + color: #3273dc; +} + +.title { + color: #222; + text-decoration: none; + border: 0; +} + +.filter { + margin-bottom: 0; +} + +time { + font-style: italic; +} + +nav a { + margin-right: 1ex; +} + +.tags { + padding: unset; + font-size: small; +} + +.tags ul { + display: inline-block; +} + +.tags li { + list-style: none; + display: inline-block; + padding-right: 1ex; +} + +textarea { + width: 100%; + font-size: 1rem; +} + +input { + font-size: 1rem; +} + +main, +article { + line-height: 1.6; +} + +table { + width: 100%; +} + +img { + max-width: 100%; +} + +code { + padding: 2px 5px; + background-color: #f2f2f2; +} + +pre code { + color: #222; + display: block; + padding: 20px; + white-space: pre-wrap; + font-size: 0.875rem; + overflow-x: auto; +} + +div.highlight pre { + background-color: initial; + color: initial; +} + +div.highlight code { + background-color: unset; + color: unset; +} + +blockquote { + border-left: 1px solid #999; + color: #222; + padding-left: 20px; + font-style: italic; +} + +footer { + padding: 25px; + text-align: center; +} + +.helptext { + color: #777; + font-size: small; +} + +/* blog posts */ +ul.h-feed { + list-style-type: none; + padding: unset; +} + +ul.h-feed li { + display: flex; +} + +ul.h-feed li span { + flex: 0 0 130px; +} + +ul.h-feed li a:visited { + color: #8b6fcb; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #333; + color: #ddd; + } + + h1, + h2, + h3, + h4, + h5, + h6, + strong, + b, + .title { + color: #eee; + } + + a { + color: #8cc2dd; + } + + code { + background-color: #777; + } + + pre code { + color: #ddd; + } + + blockquote { + color: #ccc; + } + + textarea, + input { + background-color: #252525; + color: #ddd; + } + + .helptext { + color: #aaa; + } +} diff --git a/templates/tags.html b/templates/tags.html new file mode 100644 index 0000000..79c1c09 --- /dev/null +++ b/templates/tags.html @@ -0,0 +1,43 @@ +<!doctype html> +<html lang="en-GB"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Site Title</title> + <meta name="referrer" content="no-referrer-when-downgrade" /> + <link + rel="alternate" + type="application/atom+xml" + title="Site title" + href="/atom.xml" + /> + <link href="" rel="canonical" /> + <style></style> + </head> + <body> + <a class="skip" href="#content">Skip to main content</a> + <header> + <h2> + <a href="/" class="title">Site title</a> + </h2> + <nav> + <a href="/">Home</a> + </nav> + </header> + <main id="content"> + <h3 class="filter">Tags</h3> + <ul class="tags"> + <li class="h-feed"> + <a href="/tags/tag">#tag</a> + </li> + </ul> + </main> + <footer> + Content is + <a rel="license" href="http://creativecommons.org/licenses/by/4.0/" + >CC BY 4.0</a + >. <a href="https://git.alanpearce.eu/website/">Site source code</a> is + <a href="https://opensource.org/licenses/MIT">MIT</a> + </footer> + </body> +</html> diff --git a/themes/xmin/static/css/style.css b/themes/xmin/static/css/style.css deleted file mode 100644 index e605297..0000000 --- a/themes/xmin/static/css/style.css +++ /dev/null @@ -1,75 +0,0 @@ -body { - font-family: sans-serif; - line-height: 1.5em; - margin: auto; - max-width: 800px; - padding: 1em; -} - -/* header and footer areas */ -nav > ul { padding: 0; } -nav > ul > li { display: inline-block; } -article > header, nav > ul a { - background: #eee; - border-radius: 5px; - padding: 5px; - text-decoration: none; -} -.terms { font-size: .9em; } -nav > ul, article > header, footer { text-align: center; } -.title { font-size: 1.1em; } -footer a { text-decoration: none; } -hr { - border-style: dashed; - color: #ddd; -} -body > nav { - border-bottom: 1px solid #ddd; -} -body > footer { - border-top: 1px solid #ddd; -} - -/* code */ -pre { - border: 1px solid #ddd; - overflow-x: auto; - padding: 1em; -} -code { background: #f9f9f9; } -pre code { background: none; } - -/* misc elements */ -img, iframe, video { max-width: 100%; } -main { hyphens: auto; } -blockquote { - background: #f9f9f9; - border-left: 5px solid #ccc; - padding: 3px 1em 3px; -} - -table thead th { border-bottom: 1px solid #ddd; } -th, td { padding: 5px; } -thead, tfoot, tr:nth-child(even) { background: #eee; } -.hl { background-color: #ffc; } - -@media (prefers-color-scheme: dark) { - body { - background-color: #111; - color: white; - } - article > header, nav > ul a { - background: #222; - } - a { - color: #C4D4EE; - } - a:visited { - color: #CEDEE0; - } - code { background-color: #444; } - thead, tfoot, tr:nth-child(even) { background: #222; } - .hl { - background-color: #555; - } -} diff --git a/themes/xmin/templates/base.html b/themes/xmin/templates/base.html deleted file mode 100644 index 5942342..0000000 --- a/themes/xmin/templates/base.html +++ /dev/null @@ -1,25 +0,0 @@ -<!DOCTYPE html> -<html lang="{{ lang }}"> - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title {%- if current_path == '/' %} class="p-name"{% endif %}>{% block title %}{{ section.title }} | {{ config.title }}{% endblock %}</title> - <link rel="stylesheet" href="/css/style.css" /> - {%- if config.generate_feed %} - {%- block rss %} - <link rel="alternate" type={% if config.feed_filename == "atom.xml" %}"application/atom+xml"{% else %}"application/rss+xml"{% endif %} title="RSS" href="{{ get_url(path=config.feed_filename) | safe }}"> - {%- endblock %} - {%- endif %} - </head> - <body> - <nav> - <ul> - {%- for item in config.extra.menu.main %} - <li><a {%- if item.url == "/" %} class="author"{% endif %} href="{{ item.url | safe }}">{{ item.name }}</a></li> - {%- endfor %} - </ul> - </nav> - {% block main %}{% endblock %} - <footer>{{ config.extra.footer | safe }}</footer> - </body> -</html> diff --git a/themes/xmin/templates/categories/list.html b/themes/xmin/templates/categories/list.html deleted file mode 120000 index e0e4e08..0000000 --- a/themes/xmin/templates/categories/list.html +++ /dev/null @@ -1 +0,0 @@ -../tags/list.html \ No newline at end of file diff --git a/themes/xmin/templates/categories/single.html b/themes/xmin/templates/categories/single.html deleted file mode 120000 index 86f5e80..0000000 --- a/themes/xmin/templates/categories/single.html +++ /dev/null @@ -1 +0,0 @@ -../tags/single.html \ No newline at end of file diff --git a/themes/xmin/templates/index.html b/themes/xmin/templates/index.html deleted file mode 100644 index 23ec4cd..0000000 --- a/themes/xmin/templates/index.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -<main class="h-card"> - <h1 class="p-name">{{ config.title }}</h1> - {{ section.content | safe }} - <section> - <h2>Latest Posts</h2> - <ul class="h-feed"> - {%- for page in section.pages | slice(end=3) %} - <li class="h-entry"> - <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time> - <a class="u-url p-name" href="{{ page.path | safe }}">{{ page.title }}</a> - </li> - {%- endfor %} - </ul> - </section> - <section> - <h2>Elsewhere on the Internet</h2> - <ul> - {%- for item in config.extra.menu.contact %} - <li> - {%- if item.url is starting_with("mailto:") %} - <a href="{{ item.url | safe }}" class="u-email email" rel="me">{{ item.name }}</a> - {%- else %} - <a href="{{ item.url | safe }}" class="u-url url" rel="me">{{ item.name }}</a> - {%- endif %} - </li> - {%- endfor %} - </ul> - </section> - <footer> - GPG Key: <a href="{{ config.extra.gpg_url | safe }}" rel="u-key pgpkey">{{ config.extra.gpg_fingerprint }}</a> - </footer> -</main> -{% endblock %} diff --git a/themes/xmin/templates/page.html b/themes/xmin/templates/page.html deleted file mode 100644 index f32a6fc..0000000 --- a/themes/xmin/templates/page.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} - -{% block title %} -{{- page.title -}} -{% endblock %} - -{% block main %} -<article class="h-entry"> - <header> - <h1><span class="title p-name">{{ page.title }}</span></h1> - <time class="dt-published" datetime="{{ page.date | date(format='%+') }}">{{ page.date | date(format="%F") }}</time> - <p class="terms"> - {%- if page.taxonomies %} - {%- for name, taxon in page.taxonomies %} - {{ name | capitalize }}: - {%- for item in taxon %} - <a class="p-category" href="{{ get_taxonomy_url(kind=name, name=item) }}">{{ item }}</a> - {%- endfor %} - {%- endfor %} - {%- endif %} - </p> - </header> - - <main class="e-content"> - {{ page.content | safe }} - </main> -</article> -{% endblock %} diff --git a/themes/xmin/templates/section.html b/themes/xmin/templates/section.html deleted file mode 100644 index e61566f..0000000 --- a/themes/xmin/templates/section.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -<main> - <h1>{{ section.title }}</h1> - {{ section.content }} - <section> - <ul> - {% for page in section.pages %} - <li class="h-entry"> - <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time> - <a class="u-url p-name" href="{{ page.path | safe }}">{{ page.title }}</a> - </li> - {% endfor %} - </ul> - </section> -</main> -{% endblock %} diff --git a/themes/xmin/templates/tags/list.html b/themes/xmin/templates/tags/list.html deleted file mode 100644 index ee60c39..0000000 --- a/themes/xmin/templates/tags/list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ taxonomy.name | capitalize }}{% endblock %} - -{% block main %} -<main> - <h1>{{ taxonomy.name | capitalize }}</h1> - <section> - <ul> - {%- for term in terms %} - <li> - <a href="{{ term.permalink }}">{{ term.name }}</a> - </li> - {%- endfor %} - </ul> - </section> -</main> -{% endblock %} diff --git a/themes/xmin/templates/tags/single.html b/themes/xmin/templates/tags/single.html deleted file mode 100644 index 25dde54..0000000 --- a/themes/xmin/templates/tags/single.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} - -{% block rss %} - {% set rss_path = "tags/" ~ term.name ~ "/atom.xml" %} - <link rel="alternate" type="application/atom+xml" title="RSS" href="{{ get_url(path=rss_path, trailing_slash=false) | safe }}"> - -{% endblock %} - -{% block title %}{{ taxonomy.name | capitalize }}: {{ term.name }} | {{ config.title }}{% endblock %} - -{% block main %} -<main> - <h1>{{ taxonomy.name | capitalize }}: {{ term.name }}</h1> - <section> - <ul class="h-feed"> - {%- for page in term.pages %} - <li class="h-entry"> - <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format="%F") }}</time> - <a class="u-url p-name" href="{{ page.permalink | safe }}">{{ page.title }}</a> - </li> - {%- endfor %} - </ul> - </section> -</main> -{% endblock %} diff --git a/themes/xmin/theme.toml b/themes/xmin/theme.toml deleted file mode 100644 index 99884b9..0000000 --- a/themes/xmin/theme.toml +++ /dev/null @@ -1,12 +0,0 @@ -name = "xmin" -description = "XMin is a Hugo theme written by Yihui Xie in about four hours" -license = "MIT" - -[author] -name = "Alan Pearce" -homepage = "https://www.alanpearce.eu" - -[original] -author = "yihui" -homepage = "https://yihui.org" -repo = "https://github.com/yihui/hugo-xmin" \ No newline at end of file |